Pagination
Pagination limits collection responses to manageable chunks. Three strategies: offset/limit (simple, stale data risk), cursor-based (stable, scalable), page number (user-friendly). Cursor-based pagination is the modern standard for large, frequently updated collections. Always wrap results in an envelope with pagination metadata.
Details
Pagination is mandatory for any collection that can grow unbounded. Never return all records without a limit.
Offset/limit pagination: GET /users?offset=40&limit=20 Simple to implement. Suffers from 'page drift' – if a record is inserted or deleted between requests, items can be skipped or duplicated. Use for small, stable collections or when exact page numbers matter.
Page number pagination: GET /users?page=3&per_page=20 Equivalent to offset/limit but more intuitive for UIs. Same drift problem.
Cursor-based pagination: GET /users?cursor=eyJ1c2VySWQiOiJ1c3JfMTIzIn0&limit=20 The cursor is an opaque token encoding the last seen position. Can be a base64-encoded ID, timestamp, or composite. Stable – inserts/deletes don't shift items across pages. Use for large collections (millions of records), real-time feeds, and any API consumed by mobile clients.
Response envelope with next/prev links (HATEOAS-lite): Include the next cursor or page URL in the response so clients don't need to construct pagination URLs manually.
Total count: expensive on large tables (COUNT(*) without index). Consider making it optional via ?include_total=true. Cursor pagination often omits total.
URL Examples
| Pattern | Description |
|---|---|
| GET /users?page=2&per_page=20 | Page-number pagination |
| GET /users?offset=40&limit=20 | Offset/limit pagination |
| GET /events?cursor=eyJ0cyI6MTcwMH0&limit=50 | Cursor pagination |
| GET /users?after=usr_123&limit=20 | Keyset/ID cursor |
Do
- +Always paginate collections – never return unlimited records
- +Use cursor-based pagination for large, frequently updated collections
- +Include next/prev cursor or URL in response for easy traversal
- +Set a maximum page size (e.g., limit cannot exceed 100)
- +Use consistent parameter names across all collection endpoints
Don't
- !Never return a bare array – always include pagination metadata
- !Never allow limit=0 or limit=-1 to bypass pagination
- !Never expose opaque cursor internals – cursors should be treated as opaque strings by clients
- !Avoid offset pagination for real-time feeds – use cursor instead
Examples
GET /api/v1/events?limit=50
{
"data": [ { "id": "evt_1", ... }, ... ],
"pagination": {
"limit": 50,
"hasMore": true,
"nextCursor": "eyJ0cyI6MTcyNTAwMDAwMH0",
"next": "/api/v1/events?cursor=eyJ0cyI6MTcyNTAwMDAwMH0&limit=50"
}
}
# Next page:
GET /api/v1/events?cursor=eyJ0cyI6MTcyNTAwMDAwMH0&limit=50