Cursor pagination

Paging by a pointer to the last row you saw instead of a page number, so new rows arriving never duplicate or skip results.

load more without repeating or skipping itemspaging that doesn't break when new rows arrivekeyset paginationseek paginationcursor paganationcursor-based next-page tokenpagination without OFFSETinfinite scroll that keeps showing duplicates

See it

Live demo coming soon

What it is

Instead of 'skip 40 rows, take 20', cursor pagination says 'give me the 20 rows after this exact one'. The cursor is a pointer to the last item you saw, usually an encoded copy of its sort key plus its id, and the query becomes a WHERE clause the index can jump straight to. Offset paging makes the database count and throw away every row it skips, so page 500 gets slower than page 1. Keyset paging costs the same on every page.

Reach for it on any feed, activity list, or infinite scroll where rows get inserted while people are reading. With OFFSET, three new rows arriving between page loads pushes three items you already saw onto the next page, and readers see duplicates while other items vanish entirely. A cursor is anchored to a row, not to a position, so inserts cannot shuffle it.

Gotchas: your sort key must be unique or paired with a tiebreaker (order by created_at, then id), otherwise rows sharing a timestamp get silently skipped at the page boundary. You also give up 'jump to page 47' and total counts, which is why admin tables often keep offset paging while feeds go cursor. Return the cursor as an opaque base64 string so clients never build one themselves.

Ask AI for it

Implement cursor (keyset) pagination for this list endpoint instead of OFFSET/LIMIT. Sort by created_at DESC with id DESC as a tiebreaker, and fetch with WHERE (created_at, id) < (cursor_created_at, cursor_id) LIMIT n + 1, using the extra row only to compute hasMore. Encode the cursor as an opaque base64 string of those two values, accept it as a 'cursor' query param, and return { items, nextCursor, hasMore }. Add a composite index on (created_at DESC, id DESC). Do not return a total count.

You might have meant

indexprimary keyidentifier strategyquery plan explain