Offset pagination identifies a page by position. Cursor pagination identifies the continuation point by the last row you saw.
GET /posts?page=3&page_size=20
# or more directly
GET /posts?offset=40&limit=20
# response
{
"items": [...],
"page": 3,
"page_size": 20,
"total_items": 1237,
"total_pages": 62
}
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20
OFFSET 40;
SELECT COUNT(*) FROM posts
-- total_pages = ceil(total_items / page_size)
OFFSET becomes expensive?LIMIT 20 OFFSET 500000;
PostgreSQL generally cannot jump directly to the 500,001st matching index entry. It has to walk past 500,000 + 20 rows.
Even with:
CREATE INDEX ... (created_at DESC, id DESC)
the index helps PostgreSQL produce rows in the required order, but doesn't eliminate the work of traversing the skipped entries.
GET /posts?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...
# response
{
"items": [...],
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi..."
}
After decoding the cursor
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (
TIMESTAMPTZ '2026-08-08 10:30:00+00',
91842
)
ORDER BY created_at DESC, id DESC
LIMIT 21;
Using LIMIT 21 lets the server determine whether another page exists. The server logic is conceptually:
rows := query(limit + 1)
hasNext := len(rows) > limit
if hasNext {
rows = rows[:limit]
}
var nextCursor *string
if hasNext {
last := rows[len(rows)-1]
cursor := encodeCursor(last.CreatedAt, last.ID)
nextCursor = &cursor
}
The cursor should contain the fields needed to resume the ordering position, not every filter field.
Example
| Field | Cursor? | Why |
|---|---|---|
created_at |
Yes | Part of ordering / continuation position |
id |
Yes | Tie-breaker (if created_at tie) |
sort |
Yes | Make ordering consistent (DESC or ASC) |
author_id |
Depends on UX | Filter defining the result set |
status |
Depends on UX | Filter defining the result set |
limit |
Usually no | Pagination configuration |