API Pagination: Offset vs Cursor-Based
Scale large dataset navigation: Offset/Limit SQL scan penalties, Page Drift anomalies, and high-performance Keyset Cursor pagination.
Offset Pagination I/O Scan & Drift vs Cursor Index Seek š
Comparing database disk scan overhead, pagination stability, and B-Tree index traversal.
01.1. Offset-Based Pagination: Mechanics & The O(N) Scan Penalty
Offset pagination (also known as page-number pagination) is the most intuitive pagination scheme. The client supplies a page number and page size:
httpGET /v1/posts?page=50000&limit=20
This translates to standard SQL:
sqlSELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;
The Database Performance Disaster:
Relational database storage engines (PostgreSQL heap files, MySQL InnoDB clustered indexes) cannot jump directly to row number 1,000,000 in O(1) time.
To fulfill OFFSET 1000000:
- The database query planner must scan and count through 1,000,000 index tuples or table rows.
- It sorts and tracks all 1,000,000 rows in memory/temp disk buffers.
- It discards the first 1,000,000 rows.
- It finally extracts and returns the subsequent 20 rows.
Disk I/O & Latency Degradation:
- Page 1 (
OFFSET 0):ā 1.5ms - Page 100 (
OFFSET 2,000):ā 8ms - Page 5,000 (
OFFSET 100,000):ā 140ms - Page 50,000 (
OFFSET 1,000,000):ā 1,800ms - 12,000ms(Often causing gateway timeouts /504 Gateway Timeout).
02.2. The Page Drift Anomaly (Duplicates & Skipped Records)
In any real-time system with concurrent writes (social feeds, chat messages, financial ledgers), offset pagination causes severe data anomalies:
1. Duplicate Record Drift (On Insert):
- User requests Page 1 (
LIMIT 5 OFFSET 0). The server returns items:[Post A, Post B, Post C, Post D, Post E]. - While the user is scrolling, another user publishes Post X at position 1.
- User scrolls and requests Page 2 (
LIMIT 5 OFFSET 5). - The database offsets 5 rows from the new start:
[Post X, Post A, Post B, Post C, Post D, **Post E**, ...]. - Post E is returned a second time! The client application renders duplicate content.
2. Skipped Record Drift (On Delete):
If an item on Page 1 is deleted before Page 2 is loaded, all subsequent records shift up by one position, causing the first item of Page 2 to be skipped entirely.
03.3. Cursor-Based Pagination (Keyset / Seek Pagination)
Cursor-based pagination (also called Keyset pagination) eliminates both the O(N) scan penalty and page drift by indexing off the last record retrieved:
The Cursor Concept:
Instead of asking for "Page 50", the client asks for: "The 20 items created immediately before Item X".
httpGET /v1/posts?cursor=eyJjcmVhdGVkX2F0IjoxNzE0MDAwLCJpZCI6MTA0Mn0&limit=20
Compound Index & SQL Query:
Assuming a compound B-Tree index on (created_at DESC, id DESC):
sqlSELECT id, title, created_at FROM posts WHERE (created_at, id) < ('2026-04-12 14:30:00', 1042) ORDER BY created_at DESC, id DESC LIMIT 20;
Why Keyset Pagination is O(log N) / O(1):
The database engine performs a direct B-Tree index seek to find the exact point ('2026-04-12 14:30:00', 1042) in O(log N) time, reads the next 20 contiguous leaf blocks, and stops. It never reads or counts earlier discarded rows.
Query latency remains ā 1-3ms whether fetching the 1st page or the 10,000,000th page!
04.4. Production Cursor API Response Structure
To prevent clients from parsing or depending on database column names, encode cursor pointers as opaque Base64 strings:
json{ "data": [ { "id": "post_1041", "title": "System Design Mastery", "created_at": "2026-04-12T14:29:50Z" }, { "id": "post_1040", "title": "Understanding LSM Trees", "created_at": "2026-04-12T14:28:10Z" } ], "pagination": { "limit": 2, "has_more": true, "next_cursor": "ZXlKalltbGhkR1ZrWDJGMElqb3hOekUwTURBd0xDSnBaQ0k2TVRBME1Dd3Z", "prev_cursor": null } }
Comparison Matrix:
| Feature | Offset Pagination | Cursor / Keyset Pagination |
|---|---|---|
| Query Complexity | O(N) Disk scan | O(log N) B-Tree index seek |
| Deep Page Latency | Degrades linearly (seconds) | Constant (1-3ms) |
| Real-Time Data Stability | Severe Page Drift (Duplicates/Skips) | 100% Stable |
| Direct Page Jumping | Yes (page=42) | No (Sequential next/prev only) |
| Total Record Count | Trivial (COUNT(*)) | Expensive (Omitted in high-scale feeds) |
| Primary Use Case | Admin panels, desktop tables | Mobile feeds, infinite scroll, log streams |
āļøArchitectural Trade-offs & Production Realities
Architectural Advantages
- Constant-time O(1) query execution even on billions of records
- Completely immune to page drift, duplicate items, and missed records during live inserts/deletes
- Ideal for infinite scrolling on mobile applications and real-time social feeds
Trade-offs & Constraints
- Cannot jump directly to an arbitrary page (e.g., "Jump to Page 47")
- Requires a strict deterministic compound index on unique attributes (e.g., `created_at, id`)
- Cannot easily display the total number of pages or total count without a costly separate `COUNT(*)` query
Twitter uses `since_id` and `max_id` Snowflake cursor parameters for home timeline pagination. Slack utilizes opaque `next_cursor` tokens across conversation history APIs, allowing clients to traverse billions of channel messages without table scans or duplicate message renders.
šÆ Staff+ Engineering Takeaways
- Offset pagination (`OFFSET N`) forces the database to read and discard $N$ rows, degrading to unacceptable multi-second latencies at scale.
- Offset pagination causes duplicate and skipped items when records are inserted or deleted during user scrolling.
- Cursor pagination uses compound indexed values (`WHERE id < cursor`) for constant $O(1)$ B-Tree seeks.
- Make cursors opaque Base64 strings to decouple client SDKs from internal database column structures.
Topic Knowledge Assessment š§
Step through 3 scenario questions to test your staff-level grasp.
Why does database performance degrade linearly in Offset pagination when accessing deep pages (e.g. OFFSET 2000000 LIMIT 20)?
How clear and staff-actionable was this system breakdown?