Documentation/Under the hood/Four ways a list endpoint lies to you, including ours
Four ways a list endpoint lies to you, including ours
Unbounded default reads, a silent limit clamp, dropped filters answered with 200, and a keyset cursor that empties page two. Four list-endpoint failures and how to test for them.
- api-design
- pagination
- rest
- developer-tools
- correctness
Overview
None of these return an error. That is what makes them expensive.
---
A list endpoint is the most-called route in most APIs and the least-read page of most documentation.
It returns rows. The rows look right. Nobody goes back to it.
Four failures live there.
All four answer `200`. Three are ours. One we inherited from a database's type system, and it is the one most likely to be in your code too, because it has nothing to do with us and everything to do with what happens when a value makes a round trip through base64 and comes back a different type than it left as.
1. Asking for nothing gets you everything
This is the one that surprises people most, and it is a real asymmetry in our own API today.
Send `?sort_by=name` and you get 50 rows, because a pagination parameter is present and the default page size applies. Send **no parameters at all** and you get the entire collection, unbounded, because the code reads "no pagination parameters" as "this caller does not want pagination" and not as "this caller did not specify one".
So the careless call is the expensive one and the slightly more careful call is the bounded one.
That is backwards from every intuition about API safety. It is also fine in development against 40 rows. That is why it survives all the way to production. The same call against 200,000 rows becomes a slow response and a memory spike in the same instant.
We document it. It is still the wrong default, and the reason it has not been changed is that changing it silently truncates every existing caller who relies on the current behaviour. That is a worse failure than the one it fixes.
If you are building one of these, make the unparameterised call the bounded one. A caller who said nothing has not opted out of pagination; they have failed to mention it.
2. The limit you asked for is not the limit you got
A request for an absurdly large limit does not fail. It is silently clamped to the maximum, with no field in the response saying so. The default page size, when you send a pagination parameter without a limit, is 50.
There is a reasonable argument for this. A hard `400` on an over-large limit breaks callers who were being optimistic, not malicious, and a clamp is the forgiving choice. There is a better argument against it: a caller who asked for 100,000 rows and received 1,000 has no way to tell that from a collection which genuinely holds 1,000, so they stop paginating and silently process a fraction of the data.
Anything that clamps should say it clamped. Ours does not. That is a gap, and not a design.
3. A filter you invented is a filter that is ignored
Send `?status=active` to our `GET` list route. The parameter is parsed off the query string. It is not in the set that route recognises, so it is dropped.
You get a `200`. You get a page of rows.
No warning. No `unknown_parameter` field. Nothing anywhere in the response to say that the filter you wrote did not run.
This is the one that bites in a support channel and never in a test, because the rows that come back are real rows and they look plausible. The typo `?statuss=active` behaves identically to the correct spelling in every observable way except the one that matters.
A strict endpoint rejects unknown parameters. A forgiving endpoint ignores them. The forgiving choice is defensible right up until the parameter you ignored was the one enforcing a business rule, and then it is indefensible. Check whether the API you depend on rejects or ignores, and never assume it rejects because it would be sensible to.
4. Page two, silently empty
This one is a type-system trap and not a design choice, and it is worth knowing about whatever you build on.
Our cursor is keyset rather than offset. It encodes the sort field's value and the record's uuid, and the next page is fetched with a range predicate against that pair, using the uuid as the tiebreaker for records that share a sort value. That is the correct design for a collection being written to while you page through it: an offset shifts under you when a row is inserted, and a keyset cursor does not.
The cursor travels as base64, so the value inside it comes back as a **string**.
Now the database's type rules matter. MongoDB orders values of different BSON types by type first, and in that order **String sorts below Date**. So a string is not incomparable with a date. It compares, consistently, as smaller. Postgres would have raised on the type mismatch. Mongo answers.
So: sort by a datetime field. The stored values are dates. The cursor's value is the string `"2026-09-23T11:04:00Z"`. The range predicate compares the two and answers without complaint.
Which way it fails depends on your sort direction, and both directions are bad.
**Descending** — newest first, which is what most listings do — asks for records *less than* the cursor. No date is less than a string, so page two comes back empty. Your loop terminates, your import job reports success, and you have processed 50 records out of 40,000.
**Ascending** asks for records *greater than* the cursor. Every date is greater than every string, so page two is the collection from the top again, and your loop either never ends or re-processes everything it already did.
One type mismatch, no error, and the symptom is the opposite depending on which way the user happened to sort.
The fix is to parse ISO datetime strings back into datetime objects when decoding a cursor. Four lines. Nobody writes those four lines before losing a day to them.
Two things the cursor path gets right
Both are in the same cursor path.
**A cursor is untrusted input.** Base64 is an encoding, not a signature, so anyone can decode a cursor, change it and send it back. A decoder that accepts whatever it finds is putting caller-controlled structure into a query. That is an injection path whatever your datastore is. Constrain what a decoded cursor is allowed to contain, and treat anything unexpected as a bad request. If you are building a cursor, assume it comes back modified — or sign it.
**Detecting `has_more` without counting.** Asking for `limit + 1` rows and returning `limit` of them tells you whether another page exists, without a second `COUNT` query over the collection. Total counts are opt-in, and the count runs against the query as it was before the cursor predicate was added, so `total` means "rows matching your filter" and not "rows left after this page".
What a bad cursor should do
Reject it.
The tempting alternative is to treat an unreadable cursor as no cursor and return the first page. It looks forgiving, and it is how an infinite loop starts: the client asks for the next page, gets page one, asks for the next page, gets page one. A `400` ends that in one round trip and tells the caller something true.
Four calls against anything you depend on
Against any list endpoint you depend on, including ours:
Call it with no parameters and measure the response size. Call it with `?limit=100000` and count what comes back. Send `?nonsense_field=1` and check whether it is rejected or ignored.
Then, if it has cursors, sort by a date field and page all the way to the end, counting rows as you go, and compare that count against the total.
The last one takes the longest and finds the most.
If all four come back clean, you have spent twenty minutes and gained a fact about a dependency you are betting your data on.
*(Disclosure: I work on Supero. Three of the four failures above are in our own API, described here because a reader who hits one of them without warning has a worse day than a reader who read about it. The date-cursor trap is not specific to us and is worth checking wherever you page.)*
On this page