Pagination
Offset vs cursor — and why page 5000 of your admin table times out.
25 minDifficulty 2/5api · performanceAI-writtenWritten by a model on 16 August 2026 and not yet read by a person. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.
Before this
Why this exists
Fetching page one with `LIMIT 20 OFFSET 0` is instantaneous, but fetching page 5,000 with `OFFSET 100000` causes database timeouts. Relational databases must scan and discard all 100,000 preceding rows before returning the 20 you asked for. In high-write systems, offset pagination also causes duplicate or skipped records when rows are inserted or deleted while a user navigates.
The mental model
Offset pagination is like opening a book by counting every single page from page one until you reach page 500. Cursor pagination is like dropping a bookmark at page 500 so you can flip directly to it next time.
How it works
The Cost of Offset Scanning
SQL queries using `OFFSET N` force the engine to traverse the index or table to find $N$ rows, materialize them, and then discard them before reading the target `LIMIT`. As $N$ grows, execution time scales linearly with $O(N)$ read overhead and excessive disk I/O.
Pagination Drift Under Concurrent Writes
Offset pagination is stateless across requests. If a new row is inserted at index 0 while a user moves from page 1 (`OFFSET 0`) to page 2 (`OFFSET 10`), the item previously at index 9 shifts to index 10 and appears again on page 2. Conversely, deletions cause rows to be skipped.
Keyset and Cursor Pagination
Cursor-based pagination (keyset pagination) replaces offsets with a `WHERE` condition on a sequential, indexed column, such as `WHERE id > :last_seen_id ORDER BY id ASC LIMIT 20`. The database utilizes the B-Tree index to seek directly to the cursor in $O(\log N)$ time, ignoring preceding rows entirely.
Opaque Tokens and Multi-Column Cursors
To prevent clients from coupling to database schema details, APIs serialize cursor criteria (e.g., `created_at` timestamp and tie-breaker `id`) into opaque strings (like base64-encoded JSON). If sorting by non-unique columns, the cursor must include a unique tie-breaker column to guarantee deterministic order.
The mechanism
1. Client makes an initial request: `GET /api/items?limit=20`. 2. Server executes `SELECT * FROM items ORDER BY created_at DESC, id DESC LIMIT 21` (fetching `limit + 1` to check for a next page). 3. Server returns 20 items, generates an encoded cursor from the 20th item's `(created_at, id)`, and sets `has_more = true`. 4. Client requests page 2: `GET /api/items?limit=20&cursor=eyJjcmVhdGVkX2F0Ijox...`. 5. Server decodes the cursor and queries: `SELECT * FROM items WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 21` using a composite index `(created_at, id)`. 6. The database performs an index seek directly to the tuple boundary, returning results in constant time regardless of total table depth.
sequenceDiagram
autonumber
Client->>Server: GET /items?limit=20
Server->>Database: SELECT * FROM items ORDER BY id ASC LIMIT 21
Database-->>Server: 21 rows
Server-->>Client: 20 items + next_cursor ("id:20")
Client->>Server: GET /items?limit=20&cursor=id:20
Server->>Database: SELECT * FROM items WHERE id > 20 ORDER BY id ASC LIMIT 21
Database-->>Server: 21 rows (via Index Seek)
Server-->>Client: 20 items + next_cursor ("id:40")What people get wrong
- Adding an index on the sorted column makes `OFFSET 100000` fast.
- Indexes speed up the ordering, but the database must still count and traverse past the first 100,000 indexed pointers before returning records. B-Tree indexes do not contain row-position offsets; traversing 100,000 entries requires $O(N)$ index-block reads.
- Cursor pagination makes it easy to jump directly to an arbitrary page, like page 42.
- Cursor pagination only supports sequential traversal (next and previous relative to a known boundary). Without scanning or knowing intermediate row identifiers, the system cannot compute where page 42 begins without an offset.
- Sorting by `created_at` with a cursor is sufficient for deterministic pagination.
- Non-unique sort keys produce duplicate or missing records across pages unless a unique column like `id` is included as a secondary sort key. Multiple rows can share the exact same timestamp down to the millisecond, causing the `>` or `<` boundary to skip rows sharing that timestamp.
When not to use it
- Admin tables requiring jumping directly to arbitrary page numbers (e.g., 'Go to Page 15').
- Offset pagination (with strict max limits like page <= 100) or estimated total counts.
- Static or small lookup tables (e.g., fewer than 1,000 rows) with no concurrent writes.
- Standard offset pagination or loading the entire dataset into client memory.
- Complex multi-facet search systems where sort orders change dynamically across dozens of unindexed attributes.
- Search engines like Elasticsearch using `search_after` tokens or point-in-time (PIT) snapshots.
Terms
- Offset Pagination
- — A pagination technique that skips a specified number of records using database offset clauses before returning the requested page size.
- Cursor / Keyset Pagination
- — A pagination technique that fetches records after a specific known reference value (such as an ID or timestamp) using an indexed filter.
- Index Seek
- — A database operation where the engine navigates a B-Tree index directly to matching rows in O(log N) time rather than scanning through multiple records.
- Pagination Drift
- — The phenomenon where items are duplicated or skipped across pages due to concurrent inserts or deletes during pagination.
Can you recall it?
Why does `OFFSET 50000 LIMIT 10` perform poorly in relational databases, and how does keyset pagination resolve it?