ActiveRecord and PostgreSQL: Offset pagination vs keyset pagination for large datasets under concurrent writes
0 reputation · 07 May 2020, 02:46 UTC
0 reputation · 07 May 2020, 02:46 UTC
Goal: Implement pagination for a table exceeding 100 k rows while the application experiences high insert/update concurrency, ensuring that users never see skipped or duplicate records when navigating pages.
Constraints: Standard OFFSET/LIMIT becomes costly on deep pages and can return inconsistent results under concurrent writes. Keyset (cursor) pagination avoids the scan penalty and provides stable ordering, but requires a unique, immutable sort column or a composite cursor (e.g., id + created_at), which adds client‑side complexity. ActiveRecord offers find_each/batches for background iteration but lacks a built‑in cursor API, and popular gems like Kaminari default to OFFSET.
Uncertainty: Whether to adopt cursor‑based pagination as the default strategy, retain OFFSET for its simplicity and deep‑linking capability, or hybridize the two approaches.
Questions:
For tables over 100 k rows with high insert/update concurrency, use keyset (cursor) pagination as the default. OFFSET pagination should only be kept for the very first page (or pages 1‑3) if deep‑linking to a page number is a hard requirement.
pagy (keyset extension) or ari__ord_pagination.order(created_at: :desc, id: :desc) and ensure a matching composite index CREATE INDEX ON posts (created_at DESC, id DESC).last_ts = posts.last.created_at, last_id = posts.last.id.posts = Post.where(
'(created_at < ?) OR (created_at = ? AND id < ?)',
last_ts, last_ts, last_id
).order(created_at: :desc, id: :desc).limit(20)
pagy with its :keyset option:
pagy, posts = pagy_keyset(Post.order(created_at: :desc, id: :desc), params[:cursor])
created_at, rows with identical timestamps would be collapsed, causing skips.?page=2 to an opaque ?cursor=… token; document that the token is opaque and should be treated as a single value.A hybrid approach is possible but adds complexity and breaks the notion of a stable page number. If you still need it:
Post.limit(20).offset((page-1)*20).Missing diagnostic detail: Do you already have a composite index matching your ORDER BY columns (e.g., created_at and id)? If not, adding that index is required for keyset performance; otherwise you would need to create it before adopting the cursor approach.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.