Pagination looks simple — just add LIMIT 20 OFFSET 0 and call it done. But that shortcut quietly breaks as your dataset grows and your users scroll deeper. By page 500, offset pagination is scanning 10,000 rows it will throw away, and any new insert causes rows to shift, creating duplicates or gaps in the feed. Cursor pagination fixes both problems. Here is the 2026 guide.
What changed in 2026
- Most API design guides (Stripe, GitHub, Shopify) now default to cursor pagination. Offset is considered a legacy pattern.
- React Query, SWR, and TanStack Query all have first-class support for cursor-based infinite scroll, making client-side implementation straightforward.
- GraphQL Relay spec (connections + edges + pageInfo) standardised cursor pagination across the GraphQL ecosystem, and more REST APIs adopted compatible patterns.
- Databases at scale — even Postgres on managed services like Neon or Supabase — see the cost of deep-offset queries more visibly on shared infrastructure.
How each approach works
Offset pagination tells the database: skip N rows, then return M.
-- Page 1
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- Page 500 (scans 10,000 rows, discards them, returns 20)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 9980;
Cursor pagination (keyset) tells the database: find the row after this specific value and return M.
-- Page 1
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;
-- Page 2 (uses index seek, scans only 20 rows)
SELECT * FROM posts
WHERE (created_at, id) < ('2026-05-01 10:00:00', 9876)
ORDER BY created_at DESC, id DESC
LIMIT 20;
The WHERE clause in cursor pagination is satisfied by an index seek — O(log n) regardless of page depth.
Performance comparison
| Approach |
Page 1 rows scanned |
Page 500 rows scanned |
Works with live inserts |
| OFFSET |
20 |
10,000 |
No (rows shift) |
| Cursor (keyset) |
20 |
20 |
Yes |
| Cursor (opaque token) |
20 |
20 |
Yes |
Implementing cursor pagination in a REST API
// GET /posts?cursor=<token>&limit=20
async function getPosts(cursor?: string, limit = 20) {
let where = {};
if (cursor) {
const { createdAt, id } = decodeCursor(cursor); // base64 JSON
where = { OR: [
{ createdAt: { lt: createdAt } },
{ createdAt: createdAt, id: { lt: id } },
]};
}
const posts = await db.post.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: limit + 1, // fetch one extra to know if next page exists
});
const hasNextPage = posts.length > limit;
if (hasNextPage) posts.pop();
const nextCursor = hasNextPage
? encodeCursor({ createdAt: posts.at(-1)!.createdAt, id: posts.at(-1)!.id })
: null;
return { posts, nextCursor };
}
Encode the cursor as opaque base64 so clients cannot construct arbitrary values.
The required index
-- Composite index matching the ORDER BY + WHERE columns
CREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);
Without this index, cursor pagination degrades to a sequential scan. The index must exactly match the sort order used in the query.
How to pick
- Small dataset (<5k rows), admin panel, rarely updated → Offset is fine. Simpler to implement, easy to jump to page N.
- Feed, timeline, or large dataset → Cursor / keyset. Scales, no duplicate rows on inserts.
- User wants "jump to page 47" → Offset only (cursors cannot random-access). Consider whether this is a real user need.
- Infinite scroll / "load more" → Cursor. No gaps, no duplicates.
- GraphQL API → Relay connection spec with cursors.
Common mistakes
Cursor on a non-unique column alone. If created_at has ties, your cursor can produce duplicate or skipped rows. Always include the primary key as a tiebreaker: ORDER BY created_at DESC, id DESC.
Exposing raw cursor values. If your cursor is id=1234, clients can enumerate records. Use opaque base64-encoded tokens.
Not handling the last page. When nextCursor is null, stop fetching. Many clients loop until they get an empty page — document this explicitly in your API.
Changing sort order between pages. The cursor is only valid for the same sort order used to generate it. If the client sorts differently, they need a new first page.
Using offset for search results. Search indexes (Elasticsearch, Typesense) support search_after for cursor pagination. Deep-offset search is even slower than deep-offset SQL.
What to skip
- Page numbers in user-facing URLs for feeds. Use cursor tokens; page numbers become stale the moment data changes.
- Bidirectional cursors (previous + next) unless you genuinely need them. They double implementation complexity; most feeds are append-only and only need a "next" cursor.
- Storing cursors in the database. The cursor encodes the position in the data, not a server-side state. Keep it stateless.
FAQ
Can I mix cursor and offset pagination?
Yes but carefully. Offset for low-page-count admin tables, cursor for feeds and APIs. Do not use both on the same endpoint.
How do I handle deleted rows with cursor pagination?
Deletions do not affect cursor pagination — the WHERE clause seeks past the deleted position naturally. Offset pagination has the worse problem: it shifts all subsequent pages.
What is an opaque cursor?
A base64-encoded representation of the sort key values (e.g., { "createdAt": "2026-05-01T10:00:00Z", "id": 9876 }). It is opaque to the client but parseable by the server.
Does this work with ORMs?
Yes. Prisma supports cursor pagination natively. Drizzle and Sequelize require manual WHERE clauses but the SQL is straightforward.
Where to go next