Pagination is essential for any API that returns lists — without it, a query returning millions of rows crashes your server and overwhelms clients. But there are several pagination strategies with very different performance characteristics. This guide covers them all and when to use each.
📋 Table of Contents
Why Pagination Matters
- Performance: Returning all rows is slow and memory-intensive
- Bandwidth: Clients don’t need thousands of records at once
- Database load: Bounded queries protect your database
- User experience: Loading data in pages is faster and cleaner
Strategy 1: Offset/Limit Pagination (Simple)
-- The classic approach: OFFSET and LIMIT
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40; -- page 3 (skip 40, take 20)
// Express endpoint
app.get('/products', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const products = await db.query(
'SELECT * FROM products ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
const total = await db.query('SELECT COUNT(*) FROM products');
res.json({
data: products.rows,
pagination: {
page,
limit,
total: total.rows[0].count,
totalPages: Math.ceil(total.rows[0].count / limit),
}
});
});
Pros: Simple, supports jumping to any page, shows total count.
Cons: Slow on large offsets (the database still scans all skipped rows), and results can shift if data changes between requests.
Strategy 2: Cursor Pagination (Scalable)
Instead of an offset, use a “cursor” (usually the last item’s ID or timestamp) to fetch the next page. This scales to millions of rows because the database jumps directly to the position:
-- Fetch items AFTER a cursor (much faster than large OFFSET)
SELECT * FROM products
WHERE created_at < $1 -- cursor = last item's created_at
ORDER BY created_at DESC
LIMIT 20;
app.get('/products', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const cursor = req.query.cursor; // the last item's timestamp/id
let query, params;
if (cursor) {
query = 'SELECT * FROM products WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2';
params = [cursor, limit + 1]; // fetch one extra to check for more
} else {
query = 'SELECT * FROM products ORDER BY created_at DESC LIMIT $1';
params = [limit + 1];
}
const result = await db.query(query, params);
const hasMore = result.rows.length > limit;
const items = hasMore ? result.rows.slice(0, limit) : result.rows;
const nextCursor = hasMore ? items[items.length - 1].created_at : null;
res.json({
data: items,
pagination: { nextCursor, hasMore }
});
});
Pros: Fast at any scale (no offset scanning), stable results even as data changes.
Cons: Can't jump to arbitrary pages, no total page count, slightly more complex.
Strategy 3: Keyset Pagination (Best Performance)
-- Keyset uses a unique, ordered column (often id) for precise positioning
SELECT * FROM products
WHERE (created_at, id) < ($1, $2) -- composite cursor handles ties
ORDER BY created_at DESC, id DESC
LIMIT 20;
Keyset pagination uses a composite cursor (like created_at + id) to handle rows with identical timestamps. It's the most robust high-performance approach, used by APIs serving huge datasets.
Comparison: Which to Use
| Strategy | Best For | Scale |
|---|---|---|
| Offset/Limit | Small datasets, admin UIs needing page numbers | Small-medium |
| Cursor | Feeds, infinite scroll, large datasets | Large |
| Keyset | Massive datasets, high-performance needs | Very large |
Consistent Response Format
// Offset-based response
{
"data": [ /* items */ ],
"pagination": {
"page": 3,
"limit": 20,
"total": 1543,
"totalPages": 78
}
}
// Cursor-based response
{
"data": [ /* items */ ],
"pagination": {
"nextCursor": "2026-07-20T10:30:00Z",
"hasMore": true
}
}
Performance Tips
- Index your sort column: Pagination queries need an index on the ORDER BY column, or they're slow
- Avoid COUNT(*) on huge tables: Counting all rows is expensive — cursor pagination avoids needing a total count
- Cap the limit: Enforce a maximum page size (e.g., 100) so clients can't request everything
- Use cursor/keyset for large data: Offset pagination degrades badly on large offsets — the database scans all skipped rows
- Fetch limit+1 to detect "hasMore": Request one extra row to know if there's a next page without a separate count
Frequently Asked Questions
Q: Offset or cursor pagination?
A: Offset for small datasets and admin UIs that need page numbers and jumping to specific pages. Cursor for feeds, infinite scroll, and large datasets where performance and stability matter. Cursor scales far better but can't jump to arbitrary pages.
Q: Why is my offset pagination slow?
A: OFFSET 100000 makes the database scan and discard 100,000 rows before returning your page — increasingly slow as the offset grows. Switch to cursor or keyset pagination, which jump directly to the position using an indexed column.
Q: How do I handle data changing between pages?
A: Offset pagination can skip or duplicate items if rows are added/removed between requests. Cursor pagination is stable because it anchors to a specific item's position, not a numeric offset. Use cursor pagination when data changes frequently.
Q: Do I need to return a total count?
A: For offset pagination with page numbers, usually yes. But COUNT(*) on large tables is expensive. Cursor pagination avoids it entirely (just "hasMore"). If you need approximate counts on huge tables, consider cached or estimated counts.
Q: What's a good default and maximum page size?
A: 20-25 as a default, with a maximum of 100 enforced server-side. This balances response size and number of requests. Always cap the limit so clients can't request unbounded data with ?limit=999999.
Conclusion
Proper pagination is essential for any list-returning API. Use offset/limit for small datasets and admin UIs needing page numbers, cursor pagination for feeds and large datasets, and keyset pagination for massive high-performance needs. Offset is simplest but degrades on large offsets; cursor and keyset scale to millions of rows by jumping directly to position using indexed columns. Index your sort column, cap the page size, fetch limit+1 to detect more pages, and return a consistent response format. Choosing the right strategy for your data scale keeps your API fast and your database healthy as your data grows.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment