Pagination Strategies
Pagination is the API problem that looks easy until it isn't. The naive `offset` and `limit` work for small datasets; they break in specific ways at scale. Cursor-based pagination is more robust but adds complexity. The right choice depends on the use case.
This page is about the patterns and the edge cases.
Offset/limit (the obvious approach)
```http
GET /api/orders?offset=100&limit=50
```
The server skips the first 100 results and returns the next 50.
When it works
- Small to moderate datasets
- Static data (no inserts during pagination)
- Page-style UI (page 1, 2, 3...)
When it breaks
Performance
For deep pages, the database must skip many rows:
```sql
SELECT * FROM orders ORDER BY created_at LIMIT 50 OFFSET 100000;
```
The database scans 100,050 rows to return 50. Cost is linear in offset.
Skipped or duplicated items
If items are inserted/deleted between page fetches, items shift:
- Page 1 returns items 1-50
- Item is inserted at position 1
- Page 2 returns items 51-100, but the original item 51 is now item 52 — appears on page 1 AND page 2 (or item 50 is skipped)
For mutable data, offset pagination is unstable.
Cursor-based pagination
```http
GET /api/orders?after=eyJjcmVhdGVkX2F0Ijp...&limit=50
```
The cursor encodes "where you left off" — typically the sort key of the last item plus tie-breakers.
Mechanics
```sql
SELECT * FROM orders
WHERE (created_at, id) > (?, ?)
ORDER BY created_at, id
LIMIT 50;
```
The condition is "after" the cursor. No skip; database uses index directly. O(log n) regardless of page depth.
Cursor encoding
Typical: opaque base64-encoded JSON with the sort key:
```
eyJjcmVhdGVkX2F0IjoiMjAyNi0wNC0yNlQxMjowMDowMFoiLCJpZCI6ImFiYyJ9
Decoded: { "created_at": "2026-04-26T12:00:00Z", "id": "abc" }
```
Opaque to the client; server defines the format.
When it works
- Stable for inserts/deletes (cursor is a position, not an offset)
- O(log n) at any depth
- Forward-only by default; bidirectional with both before/after cursors
When it has costs
- Cannot jump to "page 50" — must paginate through
- More complex to implement
- Cursors can become invalid if the underlying sort changes
Page-based (the API-friendly form)
```http
GET /api/orders?page=5&per_page=50
```
Equivalent to offset/limit (offset = page * per_page) but more familiar to clients. Same performance issues at depth.
For UI that shows "page 1, 2, 3..." this is what most users expect.
The ordering problem
Pagination requires a stable order. Ambiguous order produces nondeterministic results:
```sql
-- Bad: ties are resolved nondeterministically
SELECT * FROM orders ORDER BY created_at LIMIT 50 OFFSET 100;
```
Two orders with the same `created_at` may appear in different orders across calls. Pagination breaks.
Always include a tie-breaker:
```sql
SELECT * FROM orders ORDER BY created_at, id LIMIT 50 OFFSET 100;
```
`id` (assumed unique) breaks ties. Pagination is now deterministic.
Total counts
Including total count in the response is expensive:
```sql
SELECT COUNT(*) FROM orders WHERE ...;
```
For large tables, this is slow. Common compromises:
- **Approximate counts**: faster; "about 47,000 results"
- **Counts only on first page**: subsequent pages omit
- **No counts**: just "has more / no more" indicator
For UIs that show "Showing 50 of 1,234,567 results," consider whether the count is actually useful at scale.
Specific patterns
Relay-style cursor pagination
Used by GitHub, Shopify, others. Standard structure:
```graphql
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int # optional
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
```
Bidirectional, cursor-based, with explicit page info. The standard for GraphQL pagination.
Keyset pagination (SQL idiom)
Same idea as cursor; "keyset" is the term in SQL contexts:
```sql
WHERE (created_at, id) > (?, ?)
```
Uses index efficiently; no offset.
Hybrid
Some APIs offer both: cursor for default, page-based as opt-in. Lets clients choose based on their UI.
Common failure patterns
- **Offset for deep pagination.** Performance degrades; data inconsistency.
- **No tie-breaker in ORDER BY.** Nondeterministic; pagination broken.
- **Cursor encoded as the database row ID.** Tightly couples API to internal representation; cursors break on migration.
- **Cursors that include sensitive data.** A base64 cursor is opaque-looking but trivially decoded.
- **Different sort orders per page.** Cursor only valid for a specific sort.
- **Total counts on every page.** Expensive; often unnecessary.
A reasonable default
For new APIs:
- Cursor-based pagination as primary
- Optional total count (with documented cost)
- Stable, indexed sort with tie-breakers
- Opaque cursor format the server controls
For legacy / migration:
- Keep page-based for UI compatibility
- Add cursor support for new use cases (large data, exports)
Further Reading
- [ApiProtocolComparison](ApiProtocolComparison) — Pagination across protocols
- [BatchApiDesign](BatchApiDesign) — Bulk-write analog
- [GraphQlFundamentals](GraphQlFundamentals) — Pagination in GraphQL
- [WebServicesAndApis Hub](WebServicesAndApisHub) — Cluster index