ruby 76 lines · 3 tabs

Safe Pagination with Keyset (No OFFSET)

Shared by codesnips Jan 2026
3 tabs
module KeysetPageable
  extend ActiveSupport::Concern

  included do
    scope :keyset_page, ->(cursor: nil, per: 20) do
      per = per.to_i.clamp(1, 100)
      relation = order(created_at: :desc, id: :desc).limit(per + 1)

      if cursor
        relation = relation.where(
          "(#{table_name}.created_at, #{table_name}.id) < (?, ?)",
          cursor.created_at,
          cursor.id
        )
      end

      relation
    end
  end

  class_methods do
    def slice_page(records, per:)
      per = per.to_i.clamp(1, 100)
      has_more = records.size > per
      page = has_more ? records.first(per) : records
      [page, has_more]
    end
  end
end
3 files · ruby Explain with highlit

OFFSET-based pagination degrades badly on large tables because the database must scan and discard every row before the requested page, so page 10,000 pays for the 999,990 rows it skips. Keyset pagination (also called cursor or seek pagination) replaces OFFSET with a WHERE clause on the last row seen, letting an index jump straight to the next page. This snippet shows a reusable scope, an encoded cursor, and the controller that wires them together.

In keyset_pageable.rb, the keyset_page scope orders by a compound key of created_at and id — the id tiebreaker is essential because timestamps can collide, and without it rows near a boundary can be skipped or repeated. When a cursor is present it applies a strict row-value comparison (created_at, id) < (?, ?) for descending order, which Postgres can satisfy with a single index seek on (created_at DESC, id DESC). It fetches per + 1 rows so the caller can tell whether another page exists without a separate count query.

The cursor itself must survive a round trip through a URL, so Cursor in cursor.rb packs the two key components into JSON and Base64-encodes them with encode. decode is deliberately defensive: any malformed or tampered token returns nil rather than raising, so a bad ?after= param simply yields the first page instead of a 500. Note the timestamp is serialized with full sub-second precision via iso8601(6); truncating it would break the comparison against the stored created_at.

In PostsController, index decodes the incoming cursor, runs the scope, and then calls slice_page to trim the sentinel per + 1 row and decide has_more. The response returns next_cursor only when more rows remain, encoded from the genuinely last returned record. This keeps the client stateless — it just echoes the opaque token back.

The main trade-off is that keyset pagination supports only next/prev navigation, not jumping to an arbitrary page number, and it requires a stable sort backed by a matching index. It also assumes the ordering columns are immutable enough that concurrent inserts do not scramble the sequence, which created_at/id satisfies. When those constraints hold, page latency stays flat regardless of depth, making it the right default for infinite-scroll feeds and large API result sets.


Related snips

Share this code

Here's the card — post it anywhere.

Safe Pagination with Keyset (No OFFSET) — share card
Link copied