Pagination, Filtering, and Sorting

8 min read

Any API that returns a list will eventually need to limit how much it returns, let clients narrow down what they want, and let them control the order. Those three concerns — pagination, filtering, and sorting — sound straightforward and turn out to hide a startling number of bugs. Off-by-one errors at page boundaries, filter combinations that produce inconsistent results, sort orders that scramble between requests: this is exactly where careful testing earns its keep. This lesson catalogues the patterns and the tests they each deserve.

Pagination

When an endpoint can return thousands or millions of rows, returning all of them in one response is a memory bomb. Pagination splits the result into manageable pages.

Two common schemes

Offset-based — page number plus size:

GET /api/products?page=2&limit=20

The server skips (page - 1) × limit rows and returns the next limit. Easy to implement, easy to navigate ("jump to page 5"), and works well for stable datasets.

Cursor-based — opaque token pointing to "the next batch":

GET /api/products?cursor=eyJpZCI6OTAxfQ&limit=20

The server returns up to 20 items plus a nextCursor for the next batch. More resilient to data changes (new rows being inserted while you paginate) but you can't jump to "page 5" without walking through.

A typical paginated response shape:

{
  "data": [ /* up to limit items */ ],
  "meta": {
    "page": 2,
    "limit": 20,
    "totalPages": 5,
    "totalItems": 92
  }
}

Or for cursor-based:

{
  "data": [...],
  "nextCursor": "eyJpZCI6OTIxfQ",
  "hasMore": true
}

Visualising pagination

Three full pages, one short page, then nothing. The shape of bugs you're hunting:

  • Page 3 returns 4 items instead of 5 (off-by-one).
  • Page 3 reports hasMore: true (boundary error).
  • Page 4 returns the first page again (wraparound bug).
  • Page 4 returns a 500 (no out-of-range handling).

Test cases

ScenarioExpected
Page 1 with default limitItems + correct totalPages/totalItems
Last page (full)Last limit items, hasMore: false
Last page (partial)Remaining items, count matches total - (lastFullPage × limit)
Page beyond totaldata: [], hasMore: false
page=0400 (1-indexed) or first page (depends on API)
Negative page400
limit=0400
limit over the documented max400 or capped silently
Very large page (999999)empty list, never 500
All pages walked, items collectedtotal count matches meta.totalItems, no duplicates

That last test — walking every page and reconciling — catches the subtle bugs the spot-checks miss.

Filtering

Filters narrow the result set:

GET /api/users?role=admin&status=active&country=UK

Multiple filters typically combine with AND: only users that are admin and active and in the UK.

Test cases

ScenarioExpected
Single filter, valid valueOnly matching items
Multiple filters, all matchItems satisfying all conditions
No matchesdata: [] (not 404)
Unknown filter field (?floomp=x)Ignored or 400 — confirm the API's behaviour
Invalid value type (?status=42 for an enum)400 or 422
Filter on a field that doesn't exist400
Filter combined with paginationFilter applied first, then paginated correctly
Case sensitivity (role=Admin vs role=admin)Handled consistently
Filter with special charactersURL-encoded and decoded correctly

A subtlety: a no-results search should return 200 with an empty array, not 404. 404 means "this endpoint doesn't exist"; an empty result set is a successful query that found nothing. Some APIs get this wrong — flag it when you spot it.

Sorting

Sorting controls the order:

GET /api/products?sort=price&order=asc
GET /api/products?sort=-price       # leading minus = descending

Test cases

ScenarioExpected
Sort by valid field, ascendingItems in ascending order
Sort by same field, descendingItems in descending order
Sort by field not in the response400
Multiple sort keys (sort=name,createdAt)Primary then secondary order
Sort with paginated resultsOrder maintained across pages
Sort on a field with null valuesDocumented placement (nulls first / last)
Sort on an unindexed field (very large dataset)Should still complete within timeout

The combined-with-pagination test is one to actually run. A common bug: pagination uses one sort order internally and the response uses another, so item N appears on both page 1 and page 2.

Combining all three

A realistic request:

GET /api/orders?status=shipped&country=UK&sort=createdAt&order=desc&page=1&limit=10

The right semantics:

  1. Apply filters.
  2. Apply sort.
  3. Apply pagination.

Bugs at each junction:

  • Filters applied after pagination → page 1 shows results from before filtering, page 2 shows filtered results, totals are wrong.
  • Sort applied after pagination → page 1 sorted, page 2 randomly ordered.
  • Total count not adjusted for filters → totalItems: 1000 when only 12 match.

Test the combined case explicitly — don't assume passing each individually means the combination works.

Stability and consistency

Two more failure modes worth testing:

  • Stable order across requests. Two identical calls should return items in the same order. APIs sometimes use random tie-breaking in sort, producing a different order each call. That breaks pagination spectacularly.
  • Consistent count under writes. While paginating, if a row is deleted, you might skip a row in the next page. Cursor-based pagination handles this better than offset-based.

These bugs are nearly impossible to catch with a single test — you need scripts that walk the dataset, hit the endpoint repeatedly, and compare results.

⚠️ Common mistakes

  • Asserting on a fixed item order without a sort param. "The first user is always Alice." That's only true while no one inserts a row. Always pass an explicit sort to make tests deterministic.
  • Confusing 404 with empty results. No matches → 200 with an empty array. 404 means the endpoint itself is missing.
  • Skipping the last-page test. It's where off-by-one bugs hide. Test it specifically.

🎯 Practice task

Test a paginated, filterable, sortable endpoint. 30 minutes.

  1. Pick an API with all three — JSONPlaceholder's /posts?_page=1&_limit=10, GitHub's /repos/{owner}/{repo}/issues?state=open&sort=updated, or any product API you have.
  2. Write a query that combines filter + sort + pagination. Run it. Confirm the response has the shape you expected.
  3. Walk every page of a small filter result. Collect ids. Confirm: no duplicates, count matches the metadata, items appear in your sort order.
  4. Try at least three negative cases: page=0, limit=99999, unknown filter field.
  5. Try the same query twice and diff the responses. Identical? If not, you've found a stability issue.
  6. Stretch: add a row (or simulate one) between two pagination calls. What does the next call return? Off-by-one? Skipped row? Cursor-based usually handles this better — test if your API is offset-based.

You now know the bugs that hide in list endpoints. The final lesson of this chapter takes us into a different style of failure: rate limits and the retry strategies that handle them.

// tip to track lessons you complete and pick up where you left off across devices.