Wer eine Ruby-API baut, stolpert früh über die Frage: Wie paginiere ich Ergebnisse korrekt? Die Antwort hängt davon ab, ob der Client eine klassische Web-App, eine Mobile-App oder ein anderer Service ist. In diesem Artikel vergleichen wir die drei gängigen Ansätze, zeigen ihre Grenzen in der Praxis und stellen mit jsom-pagination eine Lösung vor, die speziell für JSON:API-konforme Anwendungen entwickelt wurde.
Offset vs. Cursor: Welcher Ansatz passt wann?
Die meisten Ruby-Gems setzen auf Offset-basierte Pagination (LIMIT / OFFSET in SQL). Das funktioniert gut für interne Admin-Panels und Datensätze unter 100’000 Einträgen. Sobald die Tabelle wächst, wird OFFSET 50000 zum Problem: Die Datenbank muss alle vorherigen Zeilen überspringen, bevor sie die gewünschte Seite liefert.
Cursor-basierte Pagination (auch Keyset Pagination genannt) vermeidet dieses Problem. Statt einer Seitennummer übergibt der Client den letzten gesehenen Datensatz als Referenzpunkt:
-- Offset: wird bei grossen Tabellen langsam
SELECT * FROM articles ORDER BY id LIMIT 25 OFFSET 50000;
-- Cursor: konstante Performance unabhängig von der Position
SELECT * FROM articles WHERE id > 12345 ORDER BY id LIMIT 25;
Performance-Unterschied in der Praxis
In einem unserer Rails-Projekte mit ca. 800’000 Datensätzen haben wir folgende Unterschiede gemessen:
| Ansatz | Seite 1 | Seite 1’000 | Seite 10’000 |
|---|---|---|---|
Offset (LIMIT/OFFSET) | ~2 ms | ~45 ms | ~320 ms |
Cursor (WHERE id > ?) | ~2 ms | ~2 ms | ~2 ms |
Der Unterschied wird mit wachsender Datenmenge grösser. Bei Millionen von Datensätzen kann Offset-Pagination die Antwortzeit in den Sekundenbereich treiben.
USEO’s Take
Wir verwenden Cursor-Pagination für alle öffentlichen APIs, die mobile Clients bedienen. Der Grund ist pragmatisch: Mobile Nutzer scrollen endlos, sie brauchen keine “Seite 47”. Offset-Pagination setzen wir nur dort ein, wo der Nutzer gezielt zu einer bestimmten Seite springen muss, etwa in Admin-Oberflächen. Für interne Tools ist die einfache Implementierung mit Offset den geringen Performance-Nachteil wert.
Die drei grossen Ruby-Pagination-Gems im Vergleich
Kaminari und Will_Paginate
Kaminari und Will Paginate sind die ältesten und bekanntesten Lösungen. Beide erweitern ActiveRecord-Modelle direkt:
# Kaminari
Article.page(3).per(25)
# Will Paginate
Article.paginate(page: 3, per_page: 25)
Das ist bequem, hat aber zwei konkrete Nachteile:
- Tight Coupling an ActiveRecord. Sobald die Datenquelle kein ActiveRecord ist (z.B. ein Elasticsearch-Index, eine externe API oder ein einfaches Array), braucht man Workarounds.
- Performance. Beide Gems führen zusätzlich einen
COUNT(*)Query aus, der bei grossen Tabellen ohne passenden Index teuer wird.
Pagy: Schnell, aber architektonisch starr
Pagy löst das Performance-Problem radikal. Es ist laut eigenen Benchmarks 40x schneller als Kaminari und verbraucht deutlich weniger Speicher. In unseren Projekten nutzen wir Pagy als Standard-Pagination für Rails-Views.
Allerdings basiert Pagy auf einem Modul-System mit impliziten Annahmen:
# Pagy erwartet, dass die Klasse eine `params`-Methode hat
include Pagy::Backend
# Für Metadaten und Links braucht man zusätzlich:
include Pagy::Frontend
# ...was wiederum eine `request`-Methode voraussetzt
Das funktioniert perfekt in einem Rails-Controller. Aber was, wenn die Pagination in einem Service-Objekt, einem Query-Objekt oder einer Hanami-Action stattfinden soll? Dann muss man Methoden wie params und request manuell definieren oder durchreichen.
Ausserdem verwendet Pagy standardmässig page und per_page als Parameter. Der JSON:API-Standard empfiehlt aber verschachtelte Parameter: page[number] und page[size]. Das erfordert zusätzliche Konfiguration.
JSOM-Pagination: Ein Wrapper für JSON:API-konforme APIs
Um diese Lücke zu schliessen, haben wir JSOM-Pagination gebaut. Es ist ein schlanker Wrapper um Pagy, der zwei Dinge anders macht:
- Dependency Injection statt Module. Keine impliziten Annahmen über die Aufrufer-Klasse.
- JSON:API-Parameter und -Response eingebaut.
page[number]undpage[size]werden nativ unterstützt.
Grundlegende Verwendung
require 'jsom-pagination'
paginator = JSOM::Pagination::Paginator.new
collection = Article.published
pagination_params = { number: 2, size: 25 }
paginated = paginator.call(
collection,
params: pagination_params,
base_url: 'https://api.example.com/articles'
)
Das funktioniert identisch mit Arrays, ActiveRecord-Relationen oder jeder anderen Sammlung, die count und [] unterstützt:
# Auch ein einfaches Array lässt sich paginieren
items = (1..200).to_a
paginated = paginator.call(items, params: { number: 3, size: 10 }, base_url: 'https://example.com')
Meta-Daten und Links nach JSON:API-Spezifikation
Die API-Antwort enthält automatisch die richtigen Meta-Daten und Navigations-Links:
paginated.meta.to_h
# => { total: 200, pages: 20 }
paginated.links.to_h
# => {
# first: "https://example.com?page[size]=10",
# prev: "https://example.com?page[number]=2&page[size]=10",
# self: "https://example.com?page[number]=3&page[size]=10",
# next: "https://example.com?page[number]=4&page[size]=10",
# last: "https://example.com?page[number]=20&page[size]=10"
# }
Integration mit Serialisierern
JSOM-Pagination gibt keine Serialisierungsmethode vor. Mit jsonapi-serializer sieht die vollständige Antwort so aus:
options = { meta: paginated.meta.to_h, links: paginated.links.to_h }
render json: ArticleSerializer.new(paginated.items, options)
Die resultierende JSON-Antwort entspricht der JSON:API-Spezifikation:
{
"data": [
{ "id": "51", "type": "article", "attributes": { "title": "..." } },
{ "id": "52", "type": "article", "attributes": { "title": "..." } }
],
"meta": { "total": 200, "pages": 20 },
"links": {
"first": "https://example.com?page[size]=10",
"prev": "https://example.com?page[number]=2&page[size]=10",
"self": "https://example.com?page[number]=3&page[size]=10",
"next": "https://example.com?page[number]=4&page[size]=10",
"last": "https://example.com?page[number]=20&page[size]=10"
}
}
Rails-Controller schlank halten mit einem Concern
Für Rails-Projekte empfehlen wir ein Paginable-Concern, das die gesamte Pagination-Logik kapselt:
# app/controllers/concerns/paginable.rb
module Paginable
extend ActiveSupport::Concern
def paginate(collection)
paginator.call(
collection,
params: pagination_params,
base_url: request.url
)
end
def paginator
JSOM::Pagination::Paginator.new
end
def pagination_params
params.permit(page: [:number, :size])[:page]
end
def render_collection(paginated)
options = {
meta: paginated.meta.to_h,
links: paginated.links.to_h
}
result = serializer.new(paginated.items, options)
render json: result, status: :ok
end
end
Damit wird der Controller minimal:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
include Paginable
def index
paginated = paginate(Article.published)
render_collection(paginated)
end
private
def serializer
ArticleSerializer
end
end
Der Controller weiss nichts über Seitenzahlen, Limits oder Link-Generierung. Die gesamte Pagination-Logik ist austauschbar, ohne einen einzigen Controller ändern zu müssen.
Entscheidungshilfe: Welches Gem für welchen Einsatz?
| Kriterium | Kaminari | Will Paginate | Pagy | JSOM-Pagination |
|---|---|---|---|---|
| Performance | Mittel | Mittel | Hoch | Hoch (nutzt Pagy) |
| ActiveRecord-Abhängigkeit | Ja | Ja | Nein | Nein |
| Framework-agnostisch | Nein | Nein | Ja | Ja |
| JSON:API-Support | Nein | Nein | Manuell | Eingebaut |
| View-Helpers (HTML) | Ja | Ja | Ja | Nein |
| Einsatzbereich | Rails Views | Rails Views | Universal | JSON APIs |
Faustregel: Für Server-Side-Rendered Rails-Apps ist Pagy die beste Wahl. Für JSON APIs, die dem JSON:API-Standard folgen, spart jsom-pagination den Boilerplate-Code für Parameter-Mapping und Link-Generierung.
Fazit
Pagination in Ruby-APIs ist ein gelöstes Problem, aber die Lösung hängt vom Kontext ab. Offset-Pagination ist einfach und ausreichend für kleine bis mittlere Datensätze. Cursor-Pagination ist unerlässlich für grosse Tabellen und infinite-scroll-UIs.
Für JSON:API-konforme Anwendungen schliesst JSOM-Pagination die Lücke zwischen Pagys Performance und den Anforderungen der JSON:API-Spezifikation. Es ist ein kleines Gem mit einem klaren Zweck: Pagination ohne Konfigurationsaufwand, ohne implizite Abhängigkeiten und mit standardkonformer Ausgabe.