Pagination
Pagination returns a collection in slices. There are two common strategies, and the right choice depends on how big and how dynamic your data is.
Offset pagination
Section titled “Offset pagination”The client asks for a page by position: ?page=3&limit=20 (or ?offset=40&limit=20). Simple, allows jumping to any page, and shows total counts easily.
GET /articles?page=2&limit=20Its weakness: deep offsets are slow (the database must skip all preceding rows), and inserts shift the window — if a row is added while you page, you can see duplicates or skip items.
Cursor pagination
Section titled “Cursor pagination”The client passes an opaque cursor marking where the last page ended: ?limit=20&cursor=eyJpZCI6MTQwfQ. The server returns the next slice plus the cursor for the page after.
flowchart LR A[GET /articles?limit=20] --> B[20 items + nextCursor] B --> C[GET /articles?limit=20&cursor=...] C --> D[next 20 + nextCursor]
Cursors are stable under inserts and fast at any depth, but you cannot jump to an arbitrary page and exact totals are harder. They are the better default for large or frequently-changing collections (feeds, logs, search).
Slicing and the envelope
Section titled “Slicing and the envelope”A page should be wrapped in an envelope with data plus paging metadata and links:
| Pagination Style | Advantages | Disadvantages | Fits |
|---|---|---|---|
Offset (?page=2&limit=20) | Simple, random access | Unstable when data changes | Admin tables, fixed datasets |
Cursor (?after=cursor123) | Stable, good performance on large datasets | No total count, can’t jump to a page | Feeds, infinite scroll |
Keyset (?after_id=42) | Fast, stable | Needs a unique sortable key | Large sorted collections |
Common mistakes
Section titled “Common mistakes”Offset pagination for a real-time feed Symptom:
- A feed where data is added often — page 2 shifts to page 3 as new items arrive
- Users see duplicate items or missing items while paginating
- Use cursor pagination for real-time data
No pagination default Symptom:
GET /usersreturns every record with no limit- Database and memory blow up once users number in the millions
- Always set a default limit (
limit=20) and a max limit (limit=100)
💡 Real-world examples
GitHub API:
- Cursor-style pagination via the
Linkheader:<https://api.github.com/repos?page=2>; rel="next"- Max 100 items per page (the default varies by endpoint — check that endpoint’s reference)
Stripe API:
- Cursor pagination:
GET /charges?starting_after=ch_xxx&limit=20has_more: truetells the client there’s more data- Cursor-based because charges keep growing over time
Sources: GitHub REST API pagination, Stripe API — pagination