Why pagination matters
Returning an entire table as one giant JSON array is slow, memory-hungry, and fragile. Pagination splits results into pages so responses stay small and predictable. The pattern you choose affects performance, correctness, and how clients navigate.
Offset pagination
The classic approach: ?page=2&per_page=20 (or ?limit=20&offset=20).
{
"data": [ { "id": "A21" }, { "id": "A22" } ],
"pagination": { "page": 2, "per_page": 20, "total": 1280, "total_pages": 64 }
}Pros: simple, lets users jump to any page, easy to show "Page 2 of 64." Cons: slow on large tables (the database still scans skipped rows), and unstable — if a row is inserted while the user paginates, items shift and they see duplicates or skips.
Cursor pagination
Instead of a page number, return an opaque cursor pointing at the last item. The client passes it back to get the next page:
{
"data": [ { "id": "A21" }, { "id": "A22" } ],
"pagination": {
"next_cursor": "eyJpZCI6IkEyMiJ9",
"has_next": true
}
}GET /orders?limit=20&cursor=eyJpZCI6IkEyMiJ9Pros: stable under inserts/deletes, fast even on huge datasets, ideal for infinite scroll. Cons: no "jump to page N," and you cannot show a total page count cheaply.
Keyset pagination
A concrete form of cursor pagination that uses a real sortable column instead of an opaque token:
GET /orders?limit=20&created_before=2026-05-24T08:00:00ZThe query becomes WHERE created_at < ? ORDER BY created_at DESC LIMIT 20, which uses an index efficiently. Encode the key into a cursor so clients treat it as opaque.
Comparison
| Pattern | Jump to page | Stable | Performance at scale | Best for |
|---|---|---|---|---|
| Offset | Yes | No | Degrades | Small datasets, admin tables |
| Cursor | No | Yes | Excellent | Feeds, infinite scroll, large data |
| Keyset | No | Yes | Excellent | Time-ordered data |
Response envelope tips
- Always include navigation info (
next_cursor/has_next, orpage/total_pages). - Consider HATEOAS-style links:
"links": { "next": "/orders?cursor=..." }so clients do not build URLs by hand. - Set a sane default and maximum
limit(for example default 20, max 100) to protect your backend. - Keep cursors opaque — Base64-encode the key so clients do not depend on its internal format.