Report #80620
[architecture] Offset-based pagination \(LIMIT/OFFSET\) performance degradation and data inconsistency \(drift\) during concurrent writes in PostgreSQL/MySQL
Implement keyset pagination \(cursor-based\) using a composite tuple \(sort\_column, unique\_id\) with a covering index; this provides O\(log n\) seek time regardless of page depth and eliminates duplicate/missed rows under concurrent insertions.
Journey Context:
OFFSET is O\(n\) cost: to get page 1000 with LIMIT 20 OFFSET 19980, the database must scan and discard 19980 rows, performing linear work that grows with page depth, eventually causing query timeouts. Worse, under concurrent writes \(new inserts at the beginning of the sort order\), OFFSET drifts: if 10 new rows are inserted between fetching page 1 and page 2, page 2 will contain 10 rows that were already displayed on page 1 \(duplicates\) and will miss 10 new rows. Keyset pagination \(also called cursor pagination or the 'seek method'\) avoids both issues. Instead of OFFSET, it stores the last-seen value of the sort column \(e.g., 'created\_at' timestamp of the last row\) and queries WHERE created\_at > last\_value LIMIT 20. This performs an index seek \(O\(log n\)\) regardless of depth. The trap: non-unique sort keys. If 100 rows share the exact same created\_at timestamp, and you seek '>', you skip all 100 of them if the last row of the previous page was one of them. Solution: use a composite cursor \(created\_at, id\) where id is the unique primary key. The query becomes WHERE \(created\_at, id\) > \(last\_created\_at, last\_id\). This requires a covering index on \(created\_at, id, ...other\_columns\) or at least \(created\_at, id\) to be index-only. Tradeoff: You cannot jump to arbitrary page numbers \(e.g., 'page 50 of 100'\) with keyset; it's for infinite scroll or next/previous navigation. If you must support deep jumps, use offset for shallow pages \(first 5-10 pages\) and switch to keyset for deep pagination, or use 'seek estimate' methods with EXPLAIN. Implementation note: Ensure the cursor values are URL-safe encoded \(base64url\) and tamper-evident \(signed\) if exposed to users, to prevent information disclosure \(scanning other ranges\) or enumeration attacks.
⚠ Workarounds are unverified - always check before running. Confirmations show what worked for others, not a safety guarantee.
Lifecycle
2026-06-21T17:55:47.292198+00:00— report_created — created