Active Record Pagination: Transitioning from Offset to Keyset for High-Concurrency Datasets
0 reputation · 17 Jun 2021, 16:55 UTC
0 reputation · 17 Jun 2021, 16:55 UTC
In Rails 7.x, Active Record provides standard offset-based pagination. However, as datasets grow to millions of records, the linear performance degradation of OFFSET becomes a primary driver of database latency, especially when multiple concurrent requests compete for connection pool resources.
While keyset pagination (cursor-based) is a recognized alternative to avoid scanning thousands of rows, there is no native, unified Active Record API that abstracts keyset pagination across different database adapters. This forces a choice between custom SQL implementations or third-party gems, creating inconsistency in how large-scale data is retrieved under load.
Given the goal of maintaining low latency during concurrent access to deep pages of a dataset:
Rails 7.x has no built-in keyset pagination API, so the recommended architecture is a thin, hand-written scope layer on top of Active Record relations — or a well-maintained gem such as Pagy (which ships keyset support in recent versions) — rather than custom raw SQL scattered through controllers. The scope accepts an opaque cursor, applies a WHERE filter on the ordering columns, and returns a limit + 1 result so you know whether a next page exists. This keeps everything inside the standard relation pattern (chainable, composable with where/order) while eliminating the linear scan that makes deep OFFSET pages expensive under concurrency.
Two properties matter for your case. First, keyset pagination is roughly constant-time regardless of page depth, because the database seeks directly into an index instead of scanning and discarding N rows. Second, it is stable under concurrent writes: OFFSET counts positions, so rows inserted or deleted between requests shift every later page and cause skips or duplicates; keyset anchors to row values, which do not move. For a high-traffic endpoint serving deep pages, both properties are exactly what you need. The trade-off is real: you lose random page access ("jump to page 47") and cheap total page counts. Keyset fits next/previous or infinite-scroll APIs; if your UI requires numbered pages, this is the wrong tool.
A single non-unique column like created_at is not sufficient: ties at the page boundary will silently drop or repeat rows. The standard fix is a composite key with a unique tiebreaker, almost always the primary key:
scope :after_cursor, ->(cursor) do
return all if cursor.blank?
ts, id = decode_cursor(cursor)
where("(created_at, id) > (?, ?)", ts, id)
.order(created_at: :asc, id: :asc)
endRow-value (tuple) comparison like (created_at, id) > (?, ?) is supported by PostgreSQL, MySQL, and SQLite, and it lets the database use one composite index. Two requirements follow:
(created_at, id) in the same order and direction as the ORDER BY. Without it, the query still sorts and you lose most of the benefit.Encode the cursor as an opaque value (e.g., Base64-encoded JSON of the last row's key columns) rather than exposing raw IDs. That way you can change the ordering strategy later without breaking clients, and clients cannot hand-craft cursors that bypass your assumptions.
EXPLAIN ANALYZE on the old OFFSET query at a deep page (e.g., offset 100,000) and on the keyset equivalent. Confirm the keyset plan shows an index range scan on your composite index — not a filesort or sequential scan — and compare rows scanned and execution time.COUNT(*) often becomes the new bottleneck once pagination is cheap — omit it or cache it.Gem APIs and Rails version compatibility change over time; confirm any gem you adopt supports your exact Rails version, and inspect the SQL it generates rather than trusting it uses the index. Never mix keyset and OFFSET in the same endpoint. If deep-linking to arbitrary pages is a hard requirement, you will need a hybrid design, which is a separate architectural decision.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.