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:

AnsatzSeite 1Seite 1’000Seite 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:

  1. 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.
  2. 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:

  1. Dependency Injection statt Module. Keine impliziten Annahmen über die Aufrufer-Klasse.
  2. JSON:API-Parameter und -Response eingebaut. page[number] und page[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')

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?

KriteriumKaminariWill PaginatePagyJSOM-Pagination
PerformanceMittelMittelHochHoch (nutzt Pagy)
ActiveRecord-AbhängigkeitJaJaNeinNein
Framework-agnostischNeinNeinJaJa
JSON:API-SupportNeinNeinManuellEingebaut
View-Helpers (HTML)JaJaJaNein
EinsatzbereichRails ViewsRails ViewsUniversalJSON 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.