Offset pagination identifies a page by position. Cursor pagination identifies the continuation point by the last row you saw.

Offset pagination

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)

Why 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.

Cursor pagination

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
}

What filters belongs in the 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