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
class Cursor
attr_reader :created_at, :id
def initialize(created_at:, id:)
@created_at = created_at
@id = id
end
def self.from_record(record)
new(created_at: record.created_at, id: record.id)
end
def self.decode(token)
return nil if token.blank?
payload = JSON.parse(Base64.urlsafe_decode64(token))
ts = Time.iso8601(payload.fetch("t"))
new(created_at: ts, id: Integer(payload.fetch("i")))
rescue ArgumentError, KeyError, JSON::ParserError
nil
end
def encode
payload = { "t" => created_at.iso8601(6), "i" => id }
Base64.urlsafe_encode64(JSON.generate(payload))
end
end
class PostsController < ApplicationController
PER_PAGE = 20
def index
cursor = Cursor.decode(params[:after])
records = Post.published.keyset_page(cursor: cursor, per: PER_PAGE).to_a
page, has_more = Post.slice_page(records, per: PER_PAGE)
render json: {
data: page.map { |post| serialize(post) },
next_cursor: (Cursor.from_record(page.last).encode if has_more && page.any?)
}
end
private
def serialize(post)
{ id: post.id, title: post.title, created_at: post.created_at.iso8601 }
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.