Rails Cursor Pagination: Keyset-paginatie Die Blijft Werken Waar OFFSET Onderuitgaat
Rails cursor pagination goed geregeld. Weg met OFFSET, kies keyset-paginatie voor stabiele, snelle infinite scroll en API's die productieschaal overleven.
Een founder appte me om half vier ‘s nachts omdat de “load more”-knop van zijn marketplace de primaire Postgres had platgelegd. Niet de database, de hele primary. De API had een keurige paginatie op /api/v1/listings?page=1&per_page=25, en dat werkte prima voor de eerste honderd pagina’s. Toen besloot een scraper dat pagina 8000 interessant was, besloot Postgres om 200000 rijen te lopen om er 199975 weg te gooien, en stond elke andere query erachter in de rij. De fix die nacht was een WAF-regel die page boven 500 blokkeerde. De fix een week later was het endpoint herschrijven naar Rails cursor pagination en OFFSET voor altijd vergeten.
Na negentien jaar Rails heb ik dit incident, of een stillere variant ervan, gezien bij vrijwel elk bedrijf dat ik heb geadviseerd zodra ze een paar honderdduizend rijen in een hot table hadden. Rails cursor pagination — ook wel keyset-paginatie genoemd — is een van die wijzigingen die eruitziet als een kleine refactor en achteraf blijkt een outage-preventiemaatregel te zijn. Deze post is het draaiboek dat ik engineering leads geef zodra hun paginate-calls in tranen eindigen: wat OFFSET écht doet, wat een cursor écht is, hoe je het veilig implementeert en waar de scherpe randen zitten.
Waarom OFFSET Onderuitgaat
Het mentale model dat de meeste Rails-developers hebben bij LIMIT 25 OFFSET 1000 is “sla duizend rijen over, geef me de volgende vijfentwintig.” Dat is precies wat Postgres doet, en dat is precies het probleem. Er is geen gratis skip. Om OFFSET 1000 uit te voeren, moet Postgres eerst duizend rijen in sorteervolgorde produceren en die vervolgens weggooien. Op een hot table met een miljoen rijen loopt OFFSET 500000 een half miljoen rijen door voordat er ook maar één byte terugkomt naar je Rails-app.
Draai dit op een niet-triviale tabel en kijk wat er gebeurt:
# In rails console
Listing.order(created_at: :desc).offset(50_000).limit(25).explain
Je ziet iets als Seq Scan of Index Scan gevolgd door een rijcount in de tienduizenden. EXPLAIN ANALYZE maakt het erger — de actuele tijd schaalt lineair mee met de offset. Pagina 1 is 4ms, pagina 100 is 40ms, pagina 2000 is 800ms, pagina 20000 is een timeout. Ik duik dieper in query plans in Rails EXPLAIN ANALYZE als je er nog nooit eentje hebt getraceerd.
Het tweede probleem met OFFSET is stabiliteit. Tussen het ophalen van pagina 3 en pagina 4 kan er bovenaan de lijst een nieuwe rij binnenkomen. De gebruiker ziet vervolgens dezelfde rij twee keer op pagina 4, of mist er eentje volledig. Op elke feed waarin actief geschreven wordt — orders, notificaties, marketplace-advertenties, chatberichten — is OFFSET-paginatie stilzwijgend incorrect, ook als hij snel is.
Wat Rails Cursor Pagination Eigenlijk Is
Keyset-paginatie draait de vraag om. In plaats van “geef me rijen 1001 tot 1025” vraag je “geef me de volgende 25 rijen na deze specifieke rij.” Die “specifieke rij” wordt geïdentificeerd door de waarden van de kolommen waarop je sorteert — de cursor. Postgres kan direct naar dat punt in de index springen en vandaaruit doorstromen. De kosten zijn op pagina 1 hetzelfde als op pagina 20000.
De minimale werkende Rails cursor pagination voor een single-column ordering ziet er zo uit:
# app/controllers/api/v1/listings_controller.rb
class Api::V1::ListingsController < Api::V1::BaseController
MAX_PER_PAGE = 100
def index
per_page = [params[:per_page].to_i, MAX_PER_PAGE].min.clamp(1, MAX_PER_PAGE)
scope = Listing.published.order(id: :desc)
scope = scope.where("id < ?", params[:after].to_i) if params[:after].present?
listings = scope.limit(per_page + 1).to_a
has_more = listings.size > per_page
listings = listings.first(per_page)
render json: {
data: listings.map { |l| ListingSerializer.new(l).as_json },
next_cursor: has_more ? listings.last.id : nil
}
end
end
Drie regels doen het echte werk. Sorteer op id aflopend, filter met id < cursor, vraag één extra rij zodat je de client kunt vertellen of er nog een volgende pagina is. Op een geïndexeerde id-kolom is dit O(log n) plus O(per_page) — dezelfde kosten bij rij 1 en bij rij 10 miljoen.
Ties Afhandelen: Altijd Een Tiebreaker
Het one-column voorbeeld hierboven werkt omdat id uniek is. Zodra je begint te sorteren op iets dat niet uniek is — created_at, updated_at, score, priority — heb je een tiebreaker nodig. Twee listings die in dezelfde milliseconde zijn aangemaakt zullen anders zorgen dat rijen worden overgeslagen of herhaald op cursorgrenzen.
De juiste cursor is een samengesteld (sort_column, id)-paar. Dit is de vorm die ik gebruik voor alles wat richting gebruiker gaat:
class Api::V1::ListingsController < Api::V1::BaseController
def index
per_page = params.fetch(:per_page, 25).to_i.clamp(1, 100)
scope = Listing.published.order(created_at: :desc, id: :desc)
if params[:after].present?
created_at, id = decode_cursor(params[:after])
scope = scope.where(
"(listings.created_at, listings.id) < (?, ?)",
created_at, id
)
end
listings = scope.limit(per_page + 1).to_a
has_more = listings.size > per_page
listings = listings.first(per_page)
next_cursor = has_more ? encode_cursor(listings.last) : nil
render json: { data: serialize(listings), next_cursor: next_cursor }
end
private
def encode_cursor(record)
payload = { c: record.created_at.iso8601(6), i: record.id }
Base64.urlsafe_encode64(payload.to_json, padding: false)
end
def decode_cursor(token)
payload = JSON.parse(Base64.urlsafe_decode64(token))
[Time.iso8601(payload["c"]), payload["i"].to_i]
rescue JSON::ParserError, ArgumentError
raise ActionController::BadRequest, "invalid cursor"
end
end
De row-value vergelijking (created_at, id) < (?, ?) is het belangrijke stuk. Postgres begrijpt dit als “elke rij waarvan created_at strikt kleiner is dan X, of waar created_at gelijk is aan X en id kleiner is dan Y.” Hij gebruikt een samengestelde index op (created_at, id) als je die hebt, en die zou je moeten hebben. De migratie:
class AddCompoundCursorIndexToListings < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :listings, [:created_at, :id],
order: { created_at: :desc, id: :desc },
algorithm: :concurrently,
name: "index_listings_on_created_at_id_desc"
end
end
Als de index ontbreekt, valt Postgres terug op een sort en verdampt het hele idee. Verifieer met Listing.order(created_at: :desc, id: :desc).limit(25).explain — je wilt Index Scan Backward of Index Only Scan zien, nooit Sort. Gerelateerd: pg_stat_statements laat je haarfijn zien welke van je paginatie-endpoints nog steeds sorteren waar je dacht dat dat niet zo was.
Cursors Ondertekenen Zodat Clients Ze Niet Kunnen Vervalsen
De Base64-cursor hierboven is opaak genoeg om er verstandig uit te zien in een URL, maar hij is niet tamper-proof. Een nieuwsgierige API-consumer kan hem decoderen, de timestamp veranderen en rijen opvragen die hij niet zou mogen zien — zeker als de cursor gescoped is op een filter dat client-side leeft. Voor elk endpoint waar de cursor autorisatie impliceert (bijvoorbeeld “listings waar ik toegang toe heb voor dit punt”), teken je de cursor met MessageVerifier:
class CursorCodec
KEY = "cursor.v1"
def self.encode(payload)
Rails.application.message_verifier(KEY).generate(payload, purpose: :cursor)
end
def self.decode(token)
Rails.application.message_verifier(KEY).verify(token, purpose: :cursor)
rescue ActiveSupport::MessageVerifier::InvalidSignature
raise ActionController::BadRequest, "invalid cursor"
end
end
Nu faalt een gemuteerde cursor op de handtekeningverificatie en raakt hij je database nooit. De purpose-string beschermt je tegen cross-context hergebruik — een cursor die is gegenereerd voor het ene endpoint kan niet worden gereplayed tegen een ander.
Het Volledige Rails Cursor Pagination-patroon
Voor elk team dat meer dan één gepagineerd endpoint bouwt, verpak je het hele ding in een service zodat de controller saai blijft. Dit is ongeveer wat ik op dag één extraheer:
# app/services/cursor_paginator.rb
class CursorPaginator
Result = Struct.new(:records, :next_cursor, keyword_init: true)
def initialize(scope:, order:, per_page: 25, after: nil, max_per_page: 100)
@scope = scope
@order = order # bv. { created_at: :desc, id: :desc }
@per_page = per_page.to_i.clamp(1, max_per_page)
@after = after
end
def call
scope = @scope.order(@order)
scope = apply_cursor(scope, @after) if @after.present?
records = scope.limit(@per_page + 1).to_a
has_more = records.size > @per_page
records = records.first(@per_page)
Result.new(
records: records,
next_cursor: has_more ? encode(records.last) : nil
)
end
private
def apply_cursor(scope, token)
values = decode(token)
columns = @order.keys.map { |c| "#{@scope.klass.table_name}.#{c}" }
op = @order.values.first == :desc ? "<" : ">"
scope.where("(#{columns.join(",")}) #{op} (#{Array.new(columns.size, "?").join(",")})", *values)
end
def encode(record)
payload = @order.keys.each_with_object({}) do |col, h|
value = record.public_send(col)
h[col] = value.is_a?(Time) ? value.iso8601(6) : value
end
CursorCodec.encode(payload)
end
def decode(token)
payload = CursorCodec.decode(token)
@order.keys.map do |col|
value = payload[col] || payload[col.to_s]
value.is_a?(String) && value.match?(/\dT\d/) ? Time.iso8601(value) : value
end
end
end
De controller schrompelt in tot:
class Api::V1::ListingsController < Api::V1::BaseController
def index
result = CursorPaginator.new(
scope: Listing.published.for_user(current_user),
order: { created_at: :desc, id: :desc },
per_page: params[:per_page],
after: params[:after]
).call
render json: {
data: serialize(result.records),
next_cursor: result.next_cursor
}
end
end
Elk gepagineerd endpoint in de app deelt nu dezelfde cursorvorm, dezelfde handtekening en dezelfde garanties. Nieuwe engineers stoppen met het opnieuw uitvinden van paginatie in elke PR.
Bidirectionele Paginatie Voor UI-feeds
API’s hebben meestal alleen next_cursor nodig. UI-feeds — Twitter-achtige tijdlijnen, chat scrollback, admin-dashboards met “vorige pagina”-knoppen — hebben allebei nodig. De truc is dat “vorige pagina” gewoon “volgende pagina in omgekeerde sorteervolgorde” is:
def index
direction = params[:before].present? ? :backward : :forward
cursor = params[:before] || params[:after]
order = direction == :forward ? { created_at: :desc, id: :desc }
: { created_at: :asc, id: :asc }
result = CursorPaginator.new(
scope: Listing.published, order: order,
per_page: params[:per_page], after: cursor
).call
records = direction == :forward ? result.records : result.records.reverse
render json: {
data: serialize(records),
next_cursor: direction == :forward ? result.next_cursor : encode_forward(records.last),
previous_cursor: encode_backward(records.first)
}
end
Het subtiele stuk is niet vergeten de records op de terugweg te reversen zodat de client altijd in de display order rendert. Test dit. Dit is de bug die ik senior engineers meer dan eens op een vrijdag om 16:00 heb zien shippen.
Wanneer OFFSET Nog Steeds Prima Is
Cursor-paginatie is niet gratis — je geeft random access naar pagina N op en je geeft de totale count op. Als een mens naar pagina 400 van een rapport moet springen, gaan cursors hem niet helpen. Als een stakeholder “47 van 12300 records weergegeven” nodig heeft, draai je óf een aparte COUNT(*) (prima als het aantal begrensd is en de count gecached wordt) óf gebruik je OFFSET-paginatie en accepteer je dat pagina 400 duur is.
De regels die ik hanteer:
- Publieke API’s, mobile clients, infinite scroll feeds: altijd cursor-paginatie.
- Admin CRUD-tabellen onder 100000 rijen met
page-nummers waar gebruikers op klikken: OFFSET is prima, en de saaie keuze is vaak de goede. - Overal waar een scraper
pagekan controleren: cursor, of cap het paginanummer agressief. - Overal waar de data verandert terwijl de gebruiker paged: cursor, anders ship je duplicates.
Het counter cache-patroon helpt wanneer je een totaal nodig hebt dat anders een COUNT(*) naast de gepagineerde query zou vereisen — die combinatie is een klassieke Rails-performance-val.
Veelgemaakte Fouten
Drie dingen bijten teams bij de eerste keer dat ze Rails cursor pagination op enige schaal uitrollen.
Het eerste zijn ontbrekende indexen. Een cursorquery op ongeïndexeerde kolommen is gewoon een trage sort met extra stappen. Voeg de samengestelde index toe in dezelfde PR als de codewijziging, CONCURRENTLY in productie, en verifieer het plan.
Het tweede is een order-mismatch. Als je order is created_at DESC, id DESC maar de cursorconditie is >, krijg je een lege pagina en geen duidelijke fout. Schrijf een test die om 200 records vraagt, pagineert in stukjes van 20, en asserteert dat de geconcateneerde set gelijk is aan de ruwe query met dezelfde ordering. Die test vangt bijna elke fout in dit codepad.
Het derde zijn nullable sorteerkolommen. NULL vergelijkt raar in row-value vergelijkingen en Postgres ordent nulls standaard achteraan bij ascending, vooraan bij descending. Als je pagineert over published_at DESC waar published_at NULL kan zijn, voeg dan óf NULLS LAST toe, óf een WHERE published_at IS NOT NULL-filter, óf gebruik een COALESCE in zowel de order-clause als de cursorconditie. Consistentie is belangrijker dan welke keuze je maakt.
Rails Cursor Pagination FAQ
Wat is het verschil tussen cursor pagination en keyset pagination in Rails?
Het is dezelfde techniek. “Keyset” beschrijft het mechanisme — de waarden van de sorteersleutel gebruiken om de volgende pagina te vinden. “Cursor” beschrijft de API-vorm — de client stuurt een opaak token in plaats van een paginanummer. In Rails is een cursor doorgaans een encoded (en vaak gesigneerde) keyset. Elke post of gem die over de een praat, bedoelt meestal beide.
Moet ik de pagy-gem gebruiken voor cursor-paginatie?
Pagy is uitstekend voor OFFSET-paginatie en levert inmiddels een keyset-variant (Pagy::Keyset) die goed werkt voor rechttoe-rechtaan gevallen. Voor een marketplace, een chat-app of een API met gesigneerde cursors gebruik ik nog steeds de kleine handgeschreven CursorPaginator uit dit artikel omdat de cursorvorm en de handtekeningstrategie in jouw codebase leven, niet in de internals van een gem. Voor een intern admin-dashboard is pagy prima — pak hem en ga door.
Hoe pagineer ik een joined query met cursor-paginatie?
Je kiest de sorteersleutel op de driving table, indexeert de samengestelde sleutel daar, en gebruikt select("DISTINCT ON") of een subquery om te voorkomen dat de join rijen dupliceert. De regel is dat de kolommen waarop je sorteert een rij uniek moeten identificeren ná alle joins — anders slaat de cursorvergelijking rijen over of herhaalt ze. Als je de sorteersleutel niet uniek kunt maken na de join, herstructureer dan de query zodat de driving table eerst pagineert en de joins de gerelateerde data voor die pagina ophalen.
Kan ik een live productie-API converteren van OFFSET naar cursor-paginatie zonder clients te breken?
Ja, en dit is de migratie die ik het vaakst uitvoer. Voeg after toe als nieuwe optionele parameter, houd page één release werkend, retourneer zowel next_cursor als de bestaande paginatie-metadata, en log welke clients nog steeds page sturen. Na een deprecation-window laat je page vallen en vereis je after. Mobile clients pinnen zich meestal aan een versie, dus een harde overstap breekt gebruikers; een versioned API-endpoint (/api/v2/listings) is vaak schoner dan proberen beide voor altijd te ondersteunen.
Hulp nodig bij het auditen van een Rails-API die onder echte load traag wordt — paginatie, N+1-queries of de database-laag die stilletjes opgegeven heeft? TTB Software is gespecialiseerd in Rails-performance en fractional CTO-werk voor teams voorbij het prototype-stadium. We doen dit al negentien jaar.
Related Articles
Rails HTTP Caching: ETags, fresh_when en stale? Patronen Die de Serverbelasting in Productie Verlagen
Rails HTTP caching goed gedaan. ETags, Last-Modified, fresh_when, stale?, CDN-headers en de conditional-GET patronen ...
Rails Stripe Billing: Abonnementen, Webhooks, Proratie en Dunning die de Productie Overleven
Rails Stripe billing goed gedaan. Abonnementslevenscyclus, idempotente webhooks, proratie, dunning, en de valkuilen d...
Rails LLM Kostentracking: Spend per Tenant, Budgetlimieten en Realtime Quota-handhaving
Rails LLM kostentracking die een verrassing van 40k overleeft. Tokenboekhouding per tenant, budgetlimieten, quota-han...