March 3 2025
Pagination, Filtering, and Sorting Without Bad Endpoints
How to design list endpoints that stay predictable as volume grows, filters combine, and clients need to navigate without surprises.
Andrews Ribeiro
Founder & Engineer
4 min Intermediate Systems
Track
Senior Full Stack Interview Trail
Step 9 / 14
The problem
Almost every API starts with an innocent list endpoint.
Something like:
GET /orders
At first it works. Then new needs appear:
- filter by status
- sort by date
- search by customer
- paginate instead of returning everything
- show total results
If the team keeps adding those things through improvisation, the endpoint turns into a bag of parameters with no clear contract.
And the result is usually bad on both sides:
- the client does not know what to expect
- the database pays for weak queries
Mental model
A list endpoint needs to answer four questions clearly:
- Which data set am I looking at?
- Which slice of that set do I want?
- In what order does that slice appear?
- How do I navigate it in parts without getting lost?
If one of those answers stays vague, the API starts creating surprises.
And list endpoints with surprises become bugs very easily.
Breaking the problem down
Filtering needs clear semantics
Filtering is not just “accept any query param.”
You need to make clear:
- which fields can be filtered
- whether the filter is exact, ranged, or textual
- how filters combine
- what happens when a value is invalid
When that is not clear, each client invents its own expectation.
Sorting needs to be stable
This point goes unnoticed until pagination starts breaking.
If you sort only by created_at, two rows may have the same value.
If new items arrive between one page and the next, the client may see:
- repeated item
- skipped item
- inconsistent navigation
That is why list sorting usually needs a tie-breaker.
Something like:
created_at descid desc
That makes the order deterministic.
Pagination is not just cutting data into blocks
The two most common strategies are:
offsetcursor
Offset is simple:
?limit=20&offset=40
It works well in smaller lists, administrative reports, and scenarios where the user wants to go to “page 3.”
But it suffers more when:
- the table is very large
- sorting is expensive
- new data keeps arriving all the time
Cursor is usually better when the list is alive and the order needs to stay stable:
?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...
It is not magic. It simply models the idea of “continue from here” better.
The response needs to match the cost
Not every list endpoint needs to return:
totalpage_count- numbered pages
In some cases, calculating the exact total on every request is too expensive.
So a better contract may be:
- list of items
next_cursorhas_more
Promise less, but promise something reliable.
Good endpoints also protect themselves
Overly open listing endpoints invite abuse and regressions.
It is worth limiting:
- maximum
limit - which fields may be sorted
- which filters are allowed
- combinations that are too expensive
This is not anti-product.
It is part of the contract.
A field that looks nice in UI should not automatically become free-form sort just because it exists on screen.
Exposed sorting without criteria is often just a fast way to push hidden cost into the database.
Simple example
Imagine an orders endpoint:
GET /orders?status=paid&created_from=2026-03-01&sort=created_at:desc,id:desc&limit=20
Response:
{
"items": [
{
"id": "ord_103",
"status": "paid",
"created_at": "2026-03-23T10:30:00Z"
}
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wMy0yM1QxMDozMDowMFoiLCJpZCI6Im9yZF8xMDMifQ==",
"has_more": true
}
What this contract makes clear:
statusis an exact filtercreated_fromdefines a time slice- sorting has a main field and a tie-breaker
- navigation continues through a cursor
Now compare that with a confusing endpoint:
GET /orders?filter=paid&sort=recent&page=2
Here almost everything is missing:
- what exactly does
filterfilter? - what does
recentmean? - what stable order does
page=2depend on?
The problem is not that the syntax is short.
It is that the contract is weak.
Common mistakes
- Mixing textual search, exact filters, and ranges inside the same generic parameter.
- Sorting by an unstable field and then blaming pagination.
- Exposing any field for
sortwithout thinking about index and cost. - Returning exact
totalevery time even when that cost is too high. - Choosing
offsetorcursorbecause of trend, not because of list behavior.
How a senior thinks about it
People with more experience look at listings as a product contract and an operational contract at the same time.
The reasoning usually sounds like:
This endpoint needs to be predictable for the consumer and sustainable for the operator.
That changes the conversation.
It is no longer about “which query param looks nicer.”
It becomes about:
- clear semantics
- deterministic ordering
- controlled cost
- evolution without breaking clients
What the interviewer wants to see
In interviews, this topic appears a lot when you are asked to design a list API.
The evaluator wants to see whether you think beyond superficial CRUD.
Your level rises when you:
- talk about stable ordering before pagination breaks
- separate the simplicity of
offsetfrom the more stable behavior ofcursor - mention the cost of
total, free-formsort, and filters without indexes - treat listing as a predictable contract, not CRUD with makeup
A strong answer often sounds like this:
I would treat listing as a contract. First I would define clear filters, then deterministic ordering, and only after that would I choose between offset and cursor. If the list changes a lot and grows a lot, cursor is usually safer.
A bad listing endpoint does not fail only in the database. It fails in the confidence of whoever tries to use the API without guessing behavior.
Quick summary
What to keep in your head
- A good list endpoint needs to make filtering, sorting, and pagination behavior explicit.
- Sorting without a stable rule leads to repeated items, missing items, and confusing pagination.
- Offset is simpler, but cursor is usually better for high volume or data that changes all the time.
- A maximum limit, a sort allowlist, and explicit semantics protect both the database and the client.
Practice checklist
Use this when you answer
- Can I explain why sorting needs to be deterministic before pagination?
- Can I compare offset and cursor without turning the discussion into dogma?
- Can I propose clear filters instead of ambiguous query params?
- Can I talk about operational cost when designing a list endpoint?
You finished this article
Part of the track: Senior Full Stack Interview Trail (9/14)
Share this page
Copy the link manually from the field below.