Unbounded Collections: The Anti-Pattern With a Fuse
GET /orders returning "all of them" works flawlessly until the collection grows — then it fails everywhere at once, and the fix is a breaking change to every consumer. The bound you did not design is the outage you scheduled.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The failure is a growth curve, not an event
No one designs an unbounded endpoint on purpose. It happens by omission: GET /orders returns SELECT … FROM orders serialized, the table holds 200 rows, the response is 60KB in 20ms, and the code review approves — correctly, for that day. The contract, though, says something nobody meant: *the response scales with the table*. Every cost — query time, serialization CPU, response bytes, client parse memory — is now a linear function of business success. The endpoint has a fuse, and growth is the flame.
Follow the curve. At 10,000 rows: 3MB responses, 400ms — a slow endpoint on dashboards, below alert thresholds. At 100,000: 30MB, multiple seconds — mobile clients OOM parsing it, serverless consumers hit their timeout, and the JSON serializer's allocations trigger GC pauses that degrade *other* endpoints sharing the process. At 1,000,000: the endpoint is functionally down — but only for your biggest customers, because collection size tracks account size. That is the anti-pattern's cruelest property: it fails first and worst for exactly the users who matter most, while staging (200 rows, forever) stays green.
The failure also radiates below the API. One 30MB response occupies a connection-pool slot for seconds, streams through response buffers sized for kilobytes, and its query — a full scan, since there is nothing to bound — churns the database's buffer pool, evicting the hot pages every *other* endpoint depends on. Memory pressure from a handful of concurrent large responses is how an unbounded list on one endpoint becomes tail latency on all of them (see Payload Size: 20KB, 200KB, 5MB and API Performance: The Levers You Actually Own for the levers this bypasses).
rows response query+serialize what breaks
200 60 KB ~20 ms nothing — review approves
10,000 3 MB ~400 ms dashboards feel it; nobody pages
100,000 30 MB 3–8 s mobile OOM · serverless timeouts
GC pauses hit other endpoints
1,000,000 300 MB n/a connection/CDN limits; endpoint
is down — for biggest accounts
staging row count: 200, forever. the curve only exists in prod.Why the retrofit is the expensive path
The instinctive plan — "add pagination when it becomes a problem" — mistakes what kind of change pagination is. It is not an optimization; it is a semantic change to the response shape and the completeness promise. Clients bound to a bare array must now parse an envelope; clients that assumed one fetch equals the whole truth must grow a loop; sync jobs must learn cursors, partial progress and restart semantics. That is a breaking change multiplied by every consumer (see Backward Compatibility: The Real Rules) — deployed on the provider's emergency timeline, because the trigger was an incident, against consumers who each need their own release cycle to absorb it (see API Migration: Running the Change End to End).
Worse, the interim mitigations all lie to someone. Cap the response at 10,000 rows without an envelope, and the truncation is silent — the sync consumer processes 10,000 of 40,000 rows and reports success; you have converted an outage into data corruption, which is strictly worse because nothing pages. Add a limit parameter but default it to "all", and only new, informed clients are protected — the installed base keeps burning the fuse. The only clean exits are breaking ones, which is the whole argument for never entering: the bounded envelope costs three fields on day one and a migration program on day one thousand.
The same omission hides in shapes that do not look like list endpoints. Embedded collections — a customer response carrying orders: […] inline — are unbounded lists wearing a parent object (the fix: embed a bounded preview plus a link to the paginated collection, see Response Contracts Are Not Database Rows). Export jobs that buffer "the whole dataset" into one response or one file URL fail the same way at 10x the sizes (the fix: The Async Job Pattern with chunked or streamed results, see File Upload APIs: Authorize, Upload Directly, Confirm for the direct-to-storage pattern in reverse). And batch *writes* without item caps are the request-side twin (see Large Requests and Documented Limits and Partial Failure: When 3 of 5 Succeed).
1GET /orders2→ 2003[ { "id": "ord_1", … },4 … every row in the table … ]5 6# consumers bind to: bare array,7# one fetch = complete truth.8# every later fix breaks one of those9# two assumptions — for every client.1GET /orders (limit: default 50, max 200 — enforced)2→ 2003{4 "data": [ …50 items… ],5 "next_cursor": "djEu…", // null when complete6 "has_more": true7}8 9# 200 rows or 200 million: same response cost.10# growth changes page COUNT, not page SIZE —11# and page count is the client's loop, not your outage.The two contracts cost the same to build on day one. The difference is what varies with growth: on the left, response size (a provider-side failure that breaks clients); on the right, page count (a client-side loop that was designed for it). Bounded-by-construction beats bounded-by-retrofit by exactly one migration program.
Bounded by construction
The discipline generalizes past pagination into a review-time invariant: every response has a maximum size a human chose. Lists paginate with enforced max limits (Pagination: Choosing How Lists End); embedded collections are bounded previews with links; text fields have length caps; exports stream or chunk through jobs; and the numbers are written in the contract where consumers can plan against them. The question "what is the biggest response this can produce?" belongs on the endpoint-review checklist next to "what happens on retry?" — both are questions whose answers only get expensive when discovered in production (see Start With Requirements, Not Endpoints).
Enforcement must be server-side and unconditional, because the contract is only as bounded as its least-informed client. A documented-but-unenforced limit protects nobody: the oldest integration, written before the docs mentioned limits, is precisely the one still fetching everything. And when you *do* impose bounds on a previously unbounded endpoint, treat it as the breaking change it is — telemetry first to find who would be truncated, then per-consumer outreach with the paginated path, then enforcement with loud errors (400 limit_required), never silent truncation (see Consumer-Driven Evolution: Telemetry Before Breakage). Silent truncation is the one move worse than the original sin.
- Every list: default limit + enforced max, from v1, even at 9 rows.
- Embedded collections: bounded preview + link to the paginated collection — never the full child list inline.
- Exports and reports: async jobs with chunked/streamed retrieval, never one buffered mega-response.
- Review checklist: "largest possible response?" must have a numeric answer someone chose.
- Retrofitting bounds: telemetry → outreach → loud enforcement. Silent truncation converts outages into data corruption.
Key points
- An endpoint without a designed bound scales its cost with the table: query, serialization, transfer and client parse are all linear in data growth.
- The failure curve is silent until it is not — and it hits biggest customers first, because collection size tracks account value while staging stays tiny.
- One unbounded response degrades neighbors: connection-pool slots, response buffers, GC and database buffer-pool churn make it an every-endpoint problem.
- Retrofitting pagination is a semantic breaking change (envelope + completeness) for every consumer, executed on an emergency timeline.
- Silent truncation is worse than the outage: consumers process partial data as complete, converting a loud failure into quiet corruption.
- Bound by construction: enforced limits, bounded embeds, job-based exports, and a review question — "largest possible response?" — with a chosen number.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships
GET /ordersreturning the full table — 200 rows, 20ms, review approved. - 2Consumers → API: bind to the bare array and the one-fetch-equals-everything assumption; a partner crons it hourly.
- 3Growth → endpoint: two years later the biggest account's response is 30MB; their mobile app OOMs and the partner's function times out — staging still shows 200 rows, green.
- 4Team → hotfix: caps the response at 10,000 rows overnight, without an envelope; the partner's hourly sync silently processes 10,000 of 40,000 rows and reports success.
- 5Partner → audit: discovers weeks of missing records; the incident is now data corruption with a trust cost, and the pagination migration still has to happen — under worse conditions.
- Availability inverts with value: the endpoint fails precisely for the largest accounts, while every synthetic check and small-account probe stays healthy.
- Shared-infrastructure collateral: buffer-pool churn, pool-slot hogging and GC pauses export the one endpoint's problem to the whole service's tail latency.
- The retrofit lands on every consumer simultaneously — or worse, an unversioned cap lands as silent truncation and data corruption downstream.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Enforce default and maximum page sizes on every collection endpoint from v1; the bounded envelope (`data`, `next_cursor`, `has_more`) costs three fields (see [[pagination]]).
- • Bound every embedded collection to a preview with a link to its own paginated endpoint; never inline an unbounded child list.
- • Route dataset-sized reads to an async export job with chunked retrieval instead of stretching list endpoints (see [[async-job-pattern]]).
- • Put "what is the largest response this can produce?" on the endpoint review checklist, and require a number, not a shrug.
- • Alert on response-size percentiles per endpoint — p99 payload growth release-over-release is the fuse burning, visible long before the timeout.
- • Track rows-returned distributions against account size; linear correlation is the anti-pattern's signature even while absolute numbers look safe.
- • Before enforcing bounds on a legacy endpoint, measure who would be affected (responses above the intended cap, per consumer) — the outreach list writes itself.
- • A bounded v1 absorbs growth invisibly: raising max limits, adding cursor modes and adding export paths are all additive.
- • Bounding a legacy endpoint is a deprecation program: telemetry, per-consumer migration to the paginated path, then loud enforcement — never silent truncation (see [[deprecation]]).
- • Tightening an existing limit (200 → 100) changes page counts for deployed loops — behaviorally safe for envelope-following clients, still worth announcing and versioning for the rest.
- • Bounds push work to clients: the consumer who wants everything must now loop, resume and handle partial progress — real ergonomic cost, mitigated by SDK iterators (see [[sdk-design]]).
- • Enforced limits require the pagination machinery (ordering, tiebreakers, cursors) on endpoints that might have stayed a nine-row list forever — a small premium paid on every endpoint against a catastrophic loss on a few.
- • Export jobs and preview-plus-link embeds are more surface to build and document than "return everything" — the cost of the bound is paid by the provider up front instead of by consumers at growth.
Misconceptions
has_more semantics and consumer migration.