Pagination Patterns
There are three main pagination strategies, each with different trade-offs. Let's explore when to use each one.
1. Offset-Based Pagination
The simplest approach: skip N items, return M items.
GET /api/users?offset=40&limit=20
How It Works
Items: [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] ...
↑---------↑
skip 4 return 3 (offset=4, limit=3)
Implementation
const offset = req.query.offset || 0;
const limit = req.query.limit || 20;
const results = await db.query(
'SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2',
[limit, offset]
);
Pros
- Simple to understand and implement
- Allows jumping to any page
- Easy to calculate total pages
Cons
- Slow for large offsets (database must scan skipped rows)
- Inconsistent results when data changes
- Duplicate or missing items if rows are added/deleted
2. Page-Based Pagination
A variant of offset pagination using page numbers.
GET /api/users?page=3&per_page=20
How It Works
Page 1: items 1-20
Page 2: items 21-40
Page 3: items 41-60
Internally: offset = (page - 1) * per_page
Pros
- More intuitive for users
- Easy to show "Page 3 of 10"
Cons
- Same performance issues as offset pagination
3. Cursor-Based Pagination
Uses a pointer (cursor) to the last item seen.
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=20
How It Works
Request 1: GET /api/users?limit=3
Response: items [1,2,3], nextCursor="item_3"
Request 2: GET /api/users?cursor=item_3&limit=3
Response: items [4,5,6], nextCursor="item_6"
Implementation
// Cursor is typically base64-encoded last item ID
const cursor = decodeCursor(req.query.cursor);
const limit = req.query.limit || 20;
const results = await db.query(
'SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT $2',
[cursor.id, limit]
);
const nextCursor = encodeCursor({ id: results[results.length - 1].id });
Pros
- Consistent results even when data changes
- Efficient for large datasets (uses index)
- No duplicate or missing items
Cons
- Can't jump to arbitrary page
- More complex to implement
- Cursor can become invalid
Comparison
| Feature | Offset | Page | Cursor |
|---|---|---|---|
| Jump to page | Yes | Yes | No |
| Performance | Poor at high offset | Poor at high page | Consistent |
| Consistency | Poor | Poor | Excellent |
| Implementation | Simple | Simple | Complex |
| Use case | Small data, admin UIs | User-facing lists | Large data, feeds |
Exercise: Implement Cursor Pagination
Loading JavaScript Exercise...
When to Use Each Pattern
Use Offset/Page Pagination When:
- Dataset is small (thousands of items)
- Users need to jump to specific pages
- Data doesn't change frequently
- Building admin interfaces
Use Cursor Pagination When:
- Dataset is large (millions of items)
- Building infinite scroll / "Load More"
- Data changes frequently (social feeds)
- Performance is critical
Best Practices
1. Encode Cursors
Don't expose raw IDs. Use base64 or encryption:
const cursor = Buffer.from(JSON.stringify({ id: 123 })).toString('base64');
// "eyJpZCI6MTIzfQ=="
2. Handle Invalid Cursors
{
"error": {
"code": "INVALID_CURSOR",
"message": "The cursor has expired or is invalid"
}
}
3. Consider Keyset Pagination
For complex sort orders, cursor can include multiple fields:
{
"cursor": {
"createdAt": "2024-01-15T10:00:00Z",
"id": 123
}
}
Summary
| Strategy | Best For |
|---|---|
| Offset | Small datasets, random access needed |
| Page | User-facing lists with page numbers |
| Cursor | Large datasets, infinite scroll |

