API Pagination Patterns: Cursor vs Offset vs Keyset Guide
Pagination is fundamental to every API that returns lists. Choosing the wrong strategy causes performance cliffs, duplicate results, and poor developer experience. This guide covers the three main patterns with real code and trade-off analysis.
Offset Pagination
The classic approach โ simple to build and understand but degrades on large datasets because the database must scan all skipped rows:
// GET /posts?page=3&limit=20
// Response includes metadata for UI controls
app.get('/posts', async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const [items, total] = await Promise.all([
db.query('SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset]),
db.query('SELECT COUNT(*) FROM posts'),
]);
const totalPages = Math.ceil(total.rows[0].count / limit);
res.json({
data: items.rows,
meta: {
page,
limit,
total: parseInt(total.rows[0].count),
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
links: {
first: `/posts?page=1&limit=${limit}`,
prev: page > 1 ? `/posts?page=${page - 1}&limit=${limit}` : null,
next: page < totalPages ? `/posts?page=${page + 1}&limit=${limit}` : null,
last: `/posts?page=${totalPages}&limit=${limit}`,
},
});
}); Cursor-Based Pagination (Opaque Cursor)
Cursor pagination encodes position as an opaque string. The server decodes it; clients treat it as a black box. No missing or duplicate items when data changes between pages:
// GET /posts?after=eyJpZCI6NDJ9&limit=20
// Cursor = base64(JSON.stringify({ id: 42 }))
function encodeCursor(id: number): string {
return Buffer.from(JSON.stringify({ id })).toString('base64url');
}
function decodeCursor(cursor: string): { id: number } {
return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}
app.get('/posts', async (req, res) => {
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const after = req.query.after as string | undefined;
let query: string;
let params: any[];
if (after) {
const { id } = decodeCursor(after);
query = 'SELECT * FROM posts WHERE id < $1 ORDER BY id DESC LIMIT $2';
params = [id, limit + 1]; // fetch one extra to determine hasNextPage
} else {
query = 'SELECT * FROM posts ORDER BY id DESC LIMIT $1';
params = [limit + 1];
}
const rows = await db.query(query, params);
const hasNextPage = rows.length > limit;
const items = hasNextPage ? rows.slice(0, limit) : rows;
res.json({
data: items,
pageInfo: {
hasNextPage,
endCursor: items.length ? encodeCursor(items[items.length - 1].id) : null,
},
});
}); Keyset Pagination (Seek Method)
Keyset pagination uses compound WHERE clauses matching the ORDER BY columns. It hits the index directly โ O(1) for any page depth:
-- Keyset query โ no OFFSET, uses index on (created_at, id)
-- First page
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Next page โ cursor encodes (created_at, id) of last item
SELECT * FROM posts
WHERE (created_at, id) < ('2026-01-10T12:00:00Z', 99)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Ensure the index exists for O(log n) performance
CREATE INDEX idx_posts_keyset ON posts (created_at DESC, id DESC);
-- Node.js implementation
const cursor = { createdAt: lastItem.created_at, id: lastItem.id };
const encoded = Buffer.from(JSON.stringify(cursor)).toString('base64url');
// Next request: GET /posts?cursor=eyJjcmVhdGVkQXQ...
// Server decodes and builds the WHERE clause above Offset vs Cursor vs Keyset Comparison
| Criteria | Offset | Cursor (opaque) | Keyset (seek) |
|---|---|---|---|
| Implementation | Very simple | Moderate | Moderate |
| Deep page perf | O(n) โ degrades | O(log n) | O(log n) |
| Skip to page N | Yes | No | No |
| Stable on mutations | Can miss/repeat items | Stable | Stable |
| Total count | Easy (COUNT query) | Extra query needed | Extra query needed |
| Best for | Admin UIs, small data | Feeds, APIs | High-scale read APIs |
Related Products
on GumroadDevPlaybook Interview Vault
210+ real technical interview questions with model answers, asked at Google, Meta, Amazon, Microsoft, Stripe, and Shopify. Covers Frontend (55), Backend (55), System Design (50), and DSA (50). Available in English and Traditional Chinese.
Buy on Gumroad โTypeScript Quick Reference Toolkit
5 practical reference files for TypeScript developers: cheatsheet, TS vs JS comparison, 20 utility types with real examples, tsconfig best practices template, and 15 common mistakes guide. Print it. Keep it on your desk.
Buy on Gumroad โDeveloper Interview Vault
210 technical interview questions with model answers, key points, and follow-up questions. Covers Frontend, Backend, System Design, and DSA โ bilingual English + Traditional Chinese (ZH-TW). PWYW from $29.
Buy on Gumroad โYou Might Also Need
OpenAPI Validator
Paste your OpenAPI 3.x spec (YAML or JSON) to validate structure, find missing required fields, detect duplicate paths, check for missing operationIds, and get errors and warnings with path references.
Open free tool โ
API Response Mock Generator
Generate realistic mock API responses with custom status codes, headers, and JSON body. Quick templates for 200, 401, 404, 429, 500 and more. Runs in your browser.
Open free tool โ
API Response Time Tester
Test API endpoint response times directly from your browser. Run multiple requests, see average/min/max times, status codes, and response sizes. GET, POST, PUT, DELETE support. Free.
Open free tool โ
More Free Tools
Related Articles
PostgreSQL Performance Tuning: Indexing Strategies Every Developer Should Know
Master PostgreSQL indexing in 2026: EXPLAIN ANALYZE, B-tree vs GIN vs GiST indexes, partial indexes, covering indexes, and real-world optimization patterns. With code examples and query plans.
Redis Caching Patterns for Node.js: Cache-Aside, Write-Through, and TTL Strategies
Master Redis caching patterns in Node.js: cache-aside, write-through, write-behind, read-through, and TTL strategies. Code examples, when to use each pattern, and cache invalidation techniques.
API Rate Limiting: Algorithms and Implementation Guide 2026
Complete guide to API rate limiting in 2026. Token Bucket, Leaky Bucket, Sliding Window algorithms with Node.js and Redis implementations, distributed rate limiting, 429 handling, and best practices.
Axum vs Actix-web vs Poem: Best Rust Web Frameworks in 2026
A comprehensive comparison of Axum, Actix-web, and Poem โ the top three Rust web frameworks in 2026. Covers performance benchmarks, ergonomics, middleware, ecosystem, and code examples to help you choose the right framework.
Hono vs Express: The New Node.js Server Framework Battle in 2026
A comprehensive comparison of Hono and Express in 2026 โ performance benchmarks, middleware, TypeScript support, edge deployment, ecosystem maturity, and which framework to choose for your next API.
Enjoying this tool? Get more like it.
Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.
Unlock AI-Powered Dev Tools
- โก AI Code Review, Doc Generator & SQL Builder
- โก All premium templates & early access
- โก Member discounts on Gumroad products
$9/mo after trial ยท Cancel anytime
Want the full toolkit?
Get DevPlaybook Pro
Every template, guide, boilerplate, and automation script in one bundle. 13 premium products โ grab them all at once and save big.
MIT licensed ยท Instant download ยท No subscription
See what's included โ Browse all deals โFrequently Asked Questions
What is the simplest pagination strategy to implement?▼
Offset pagination (page and limit query parameters) is the simplest to implement and understand. You pass page=2&limit=20 and the server skips (page-1)*limit records. It works well with SQL LIMIT and OFFSET, is easy to build UI for (page numbers, jump to page), and is familiar to most developers. The main drawbacks are performance on large datasets and the possibility of duplicate or missed items when records are inserted or deleted during pagination.
When should I use cursor-based pagination?▼
Use cursor-based pagination when your dataset is large (millions of rows), when items are frequently inserted or deleted, or when you need real-time feeds (like social media timelines). A cursor encodes the position of the last fetched item as an opaque string (usually base64-encoded id or timestamp). The server uses it to fetch the next page efficiently without counting skipped rows, making it O(1) regardless of page depth.
What is keyset pagination?▼
Keyset pagination is a database-efficient variant of cursor pagination where the cursor is a set of column values that uniquely identifies a position. Instead of OFFSET, the query uses WHERE (created_at, id) < (cursor_time, cursor_id) ORDER BY created_at DESC, id DESC. This uses the database index efficiently and performs consistently even on page 10,000. It is sometimes called seek-method pagination.
What are Link headers in REST pagination?▼
RFC 5988 defines Link headers as a standard way to communicate pagination links in HTTP responses without embedding them in the response body. GitHub's API popularized this pattern: Link: <https://api.example.com/items?page=3>; rel='next', <https://api.example.com/items?page=1>; rel='first'. This keeps the response body clean (just the data array) and allows clients to follow links generically.
What is the Relay connection spec?▼
The Relay connection specification is a GraphQL pagination convention popularized by Facebook's Relay client. It defines a standardized shape: edges array (each with a node and cursor), pageInfo object (hasNextPage, hasPreviousPage, startCursor, endCursor), and the use of first/after and last/before arguments. Many GraphQL APIs adopt this spec even without using Relay to give clients predictable cursor pagination.
What is the maximum page size I should allow?▼
A common best practice is to cap page size at 100-200 items and default to 20-50. Allowing unlimited page sizes exposes your API to denial-of-service via requests that force the database to scan millions of rows or serialize enormous JSON responses. Always validate and enforce a maximum limit server-side, even if the client passes a higher value.