API Pagination Strategies
Choose the right pagination method for your REST API: cursor, offset, keyset, or page-based
Why Pagination Matters
Pagination is essential for REST APIs that return large datasets. Without it, responses become slow, memory-intensive, and unusable for clients. The right pagination strategy depends on your data characteristics and client needs.
- Reduces response payload size
- Improves API performance
- Better user experience
- Lower server memory usage
- Enables real-time data handling
{
"data": [...],
"next_cursor": "xyz789",
"has_more": true
}
1. Offset/Limit Pagination
The simplest pagination approach using offset and limit parameters. Skip N records and return M records.
Request
Response
{
"data": [
{ "id": 21, "name": "User 21", "email": "user21@example.com" },
{ "id": 22, "name": "User 22", "email": "user22@example.com" },
// ... 8 more users
],
"offset": 20,
"limit": 10,
"total": 150
}
✅ Pros
- Simple to implement
- Random access to any page
- Easy to understand for clients
- Works with any database
❌ Cons
- Poor performance on large datasets (O(n) offset)
- Inconsistent results with real-time data
- Duplicates/skips when data changes
- Database must count all skipped rows
SQL Query
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10 OFFSET 20; -- Scans 30 rows, returns 10
2. Cursor-Based Pagination
Uses an opaque cursor (often base64-encoded) to mark the position in the dataset. The most robust approach for real-time data and infinite scrolling.
Request
Response
{
"data": [
{ "id": 101, "name": "User 101", "email": "user101@example.com" },
{ "id": 102, "name": "User 102", "email": "user102@example.com" },
// ... 8 more users
],
"cursors": {
"next": "eyJpZCI6MTEwfQ",
"prev": "eyJpZCI6MTAwfQ"
},
"has_more": true
}
✅ Pros
- Consistent results with real-time data
- Excellent performance (O(1) with index)
- No duplicates or skipped records
- Perfect for infinite scroll
❌ Cons
- No random access (can't jump to page 5)
- Can't show total count efficiently
- More complex implementation
- Cursors can become invalid
Implementation Example (Node.js)
// Encode cursor
function encodeCursor(id) {
return Buffer.from(JSON.stringify({ id })).toString('base64');
}
// Decode cursor
function decodeCursor(cursor) {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
}
// Query with cursor
async function getUsers(cursor, limit = 10) {
let query = 'SELECT * FROM users';
const params = [];
if (cursor) {
const { id } = decodeCursor(cursor);
query += ' WHERE id > $1';
params.push(id);
}
query += ' ORDER BY id ASC LIMIT $' + (params.length + 1);
params.push(limit + 1); // Fetch one extra to check has_more
const rows = await db.query(query, params);
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, -1) : rows;
return {
data,
cursors: {
next: hasMore ? encodeCursor(data[data.length - 1].id) : null,
prev: cursor || null
},
has_more: hasMore
};
}
3. Keyset Pagination
Similar to cursor-based but uses visible column values instead of opaque cursors. Ideal when sorting by a unique, indexed column like ID or timestamp.
Request
Response
{
"data": [
{ "id": 101, "name": "User 101", "created_at": "2024-01-15T10:30:00Z" },
{ "id": 102, "name": "User 102", "created_at": "2024-01-15T11:00:00Z" },
// ... 8 more users
],
"after_id": 110,
"has_more": true
}
✅ Pros
- Most performant for large datasets
- Transparent (visible key values)
- Consistent with real-time data
- Uses database indexes efficiently
❌ Cons
- No random access
- Requires unique, indexed sort column
- Complex with multiple sort columns
- Exposes internal IDs
SQL Query (Highly Optimized)
-- Simple keyset on ID
SELECT * FROM users
WHERE id > 100
ORDER BY id ASC
LIMIT 10;
-- Keyset with multiple columns (created_at, id)
SELECT * FROM users
WHERE (created_at, id) > ('2024-01-15T10:30:00Z', 100)
ORDER BY created_at ASC, id ASC
LIMIT 10;
4. Page-Based Pagination
The most user-friendly approach using page numbers. Internally converts to offset/limit but provides a cleaner interface for clients.
Request
Response
{
"data": [
{ "id": 11, "name": "User 11", "email": "user11@example.com" },
{ "id": 12, "name": "User 12", "email": "user12@example.com" },
// ... 8 more users
],
"page": 2,
"per_page": 10,
"total": 150,
"total_pages": 15
}
✅ Pros
- Intuitive for end users
- Easy to build pagination UI
- Random access to any page
- Shows total pages
❌ Cons
- Same performance issues as offset
- Inconsistent with real-time data
- Total count can be expensive
- Not suitable for large datasets
Conversion Formula
// Page to offset conversion
const offset = (page - 1) * per_page;
const limit = per_page;
// Example: page=2, per_page=10
// offset = (2 - 1) * 10 = 10
// SQL: LIMIT 10 OFFSET 10
5. HATEOAS Pagination Links
Following REST principles, include hypermedia links in responses. Clients don't need to construct URLs—they follow the provided links.
Complete HATEOAS Response
{
"data": [
{
"id": 11,
"name": "User 11",
"email": "user11@example.com",
"_links": {
"self": "/api/users/11",
"orders": "/api/users/11/orders"
}
},
// ... more users
],
"links": {
"self": "/api/users?page=2",
"first": "/api/users?page=1",
"prev": "/api/users?page=1",
"next": "/api/users?page=3",
"last": "/api/users?page=10"
},
"meta": {
"total": 100,
"per_page": 10,
"current_page": 2,
"total_pages": 10,
"from": 11,
"to": 20
}
}
Link Relations (RFC 8288)
Standard link relation types for pagination:
self- Current pagefirst- First page of resultsprev- Previous page (omit on first page)next- Next page (omit on last page)last- Last page of results
HTTP Link Header Alternative
HTTP/1.1 200 OK
Link: </api/users?page=1>; rel="first",
</api/users?page=1>; rel="prev",
</api/users?page=3>; rel="next",
</api/users?page=10>; rel="last"
X-Total-Count: 100
X-Page: 2
X-Per-Page: 10
6. Comparison: When to Use Each
| Strategy | Performance | Random Access | Real-time Safe | Best For |
|---|---|---|---|---|
| Offset/Limit | O(n) | Yes | No | Small datasets, admin panels |
| Cursor-Based | O(1) | No | Yes | Social feeds, infinite scroll |
| Keyset | O(1) | No | Yes | Large datasets, logs, analytics |
| Page-Based | O(n) | Yes | No | E-commerce, search results |
Decision Guide
Mobile/Infinite Scroll
Use cursor-based pagination. Handles real-time data well and is efficient for "load more" patterns.
E-commerce/Search
Use page-based pagination. Users expect page numbers and the ability to jump to specific pages.
Analytics/Logs
Use keyset pagination. Handles millions of rows efficiently with time-based sorting.
Admin Dashboards
Use offset/limit or page-based. Datasets are usually smaller and random access is helpful.
Quick Reference
// Offset/Limit
GET /api/users?offset=20&limit=10
// Cursor-Based
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=10
// Keyset
GET /api/users?after_id=100&limit=10
// Page-Based
GET /api/users?page=3&per_page=10
Pagination Best Practices
🔢 Set Reasonable Defaults
limit=20 (default), max_limit=100
limit=10000 or no limit
📄 Include Metadata
total, has_more, next_cursor
🔗 Provide Navigation Links
next, prev, first, last links
⚡ Optimize Count Queries
has_more flag
SELECT COUNT(*) on every request