ruby 74 lines · 4 tabs

Keyset (Cursor) Pagination for ActiveRecord in Rails

Shared by codesnips Aug 2026
4 tabs
module Keysettable
  extend ActiveSupport::Concern

  included do
    scope :keyset_page, ->(after: nil, limit: 20) do
      relation = order(created_at: :asc, id: :asc).limit(limit + 1)

      if after.present?
        relation = relation.where(
          "(#{table_name}.created_at, #{table_name}.id) > (?, ?)",
          after[:created_at],
          after[:id]
        )
      end

      relation
    end
  end
end
4 files · ruby Explain with highlit

Offset pagination (LIMIT ... OFFSET ...) degrades badly on large tables: the database still has to scan and discard every skipped row, and rows shifting between requests cause duplicates or gaps. Keyset pagination, also called cursor pagination, fixes both problems by remembering the last row seen and asking for rows strictly after it in a stable sort order, which lets an index seek jump straight to the next page in roughly constant time.

The Keysettable concern mixes a keyset_page scope into any model. It sorts by a tuple of columns — here (created_at, id) — where id acts as a tiebreaker so the order is total and deterministic even when two records share a timestamp. The heart of it is the row-value comparison (created_at, id) < (?, ?), a SQL feature (well supported on PostgreSQL) that compares tuples lexicographically. This expresses "everything after the cursor" in a single indexable predicate instead of a tangle of nested OR conditions. The scope fetches one extra row (limit + 1) so the caller can tell whether a further page exists without a second COUNT query.

The Cursor value object encodes and decodes the opaque cursor. It packs the two key values into JSON, then Base64-URL-encodes them so the client treats it as a meaningless token. Cursor.encode turns a record into a string; Cursor.decode parses it back, tolerating a blank or malformed cursor by returning nil so a bad token just starts from the beginning rather than raising.

In ArticlesController, the index action decodes the incoming params[:cursor], calls keyset_page, then splits the results: if more than per_page rows came back, the extra row is dropped and a next_cursor is built from the last kept record. That cursor is returned in the JSON payload so the client can request the following page. The next_cursor is nil on the final page, giving clients a clean stop condition.

The main trade-off is that keyset pagination only supports next/previous traversal, not random "jump to page 50" access, and the sort columns must be backed by a composite index ((created_at, id)) to stay fast. For infinite-scroll feeds and large API result sets, that trade is almost always worth it.


Related snips

Share this code

Here's the card — post it anywhere.

Keyset (Cursor) Pagination for ActiveRecord in Rails — share card
Link copied