Ein typisches API-Problem aus der Praxis: Eine Liste wird mit offset und limit paginiert, während im Hintergrund neue Datensätze entstehen oder bestehende aktualisiert werden. Nutzer:innen sehen doppelte Einträge, überspringen Elemente oder erhalten bei identischem Request unterschiedliche Ergebnisse. Das ist kein Edge-Case, sondern die normale Folge aus parallelen Writes, nicht-deterministischer Sortierung und fehlender Stabilität über mehrere Seiten.
Sauber gelöst wird das meist mit Cursor-Pagination: Statt „Seite 5“ zu adressieren, wird „ab dem letzten gesehenen Element“ abgefragt. Das verschiebt die Verantwortung von einer abstrakten Seitenzahl hin zu einer konkreten Position in einer stabil sortierten Menge. Entscheidend ist dabei nicht nur das API-Design, sondern vor allem die Datenbank-Query, die Sortierregeln und die Indexierung.
Warum Offset/Limit in produktiven APIs brüchig wird
Writes zwischen den Seiten: Duplikate und Lücken sind logisch
Offset-Pagination arbeitet mit einem relativen Index: OFFSET 40 LIMIT 20 bedeutet „überspringe die ersten 40 Treffer der aktuellen Ergebnismenge“. Wenn sich die Ergebnismenge zwischen Request 1 und Request 2 verändert (Insert/Delete/Update, das die Sortierung beeinflusst), verschiebt sich die Reihenfolge. Ein Element kann auf eine frühere Position rutschen und dadurch auf der nächsten Seite erneut erscheinen. Umgekehrt können Elemente nach hinten rutschen und dadurch vollständig übersprungen werden.
Das Problem tritt auch auf, wenn keine Datensätze gelöscht werden: Schon ein Insert, das in der Sortierung „vor“ der aktuellen Position landet, verschiebt alles dahinter um 1. Offset ist dann kein stabiler Marker, sondern eine Momentaufnahme.
Fehlende deterministische Sortierung: „ORDER BY created_at“ reicht oft nicht
Ein häufiger Fehler ist eine Sortierung über ein Feld, das nicht eindeutig ist (z. B. Sekundenauflösung in created_at). Ohne eindeutigen Tie-Breaker ist die Reihenfolge innerhalb gleicher Werte nicht garantiert. Dann kann die Datenbank für zwei Requests unterschiedliche Reihenfolgen liefern, obwohl die Daten gleich sind. Pagination „zittert“: einzelne Zeilen wandern zwischen Seiten.
Stabil wird es erst mit einer totalen Ordnung: neben dem Sortierfeld ein eindeutiges Feld (typisch id) als sekundärer Sortierschlüssel. Beispiel: ORDER BY created_at DESC, id DESC.
Performance: große Offsets sind teuer
Große Offsets führen dazu, dass die Datenbank viele Zeilen „durchlaufen“ muss, bevor sie die eigentlichen Treffer liefert. Selbst mit Index kann das bedeuten: scannen, verwerfen, dann erst liefern. Cursor-basierte Abfragen können dagegen direkt „ab Position X“ starten, sofern ein passender Index existiert.
Cursor-Pagination: Prinzip, Begriffe und typische API-Form
Keyset statt Offset: „größer/kleiner als letzter Schlüssel“
Cursor-Pagination wird auch Keyset-Pagination genannt. Die Idee: Der Client erhält nach einer Seite einen Cursor (Token), der die Position beschreibt. Der nächste Request liefert Einträge „nach“ diesem Cursor, z. B. alle Datensätze mit (created_at, id) kleiner als der letzte Datensatz der vorherigen Seite (bei absteigender Sortierung).
Eine typische API-Form:
GET /items?limit=50→ liefert 50 Einträge +next_cursorGET /items?limit=50&cursor=...→ nächste Seite
Wichtig ist: Der Cursor ist kein Seitenzähler, sondern ein Positionsmarker. Damit bleibt die Folge stabil, selbst wenn neue Elemente „vorne“ dazukommen.
Cursor-Token: opaque vs. lesbar
In der Praxis hat sich ein „opaque“ Token bewährt: Der Client behandelt den Cursor als Blackbox. Intern kann er die relevanten Sortierwerte enthalten (z. B. Zeitstempel + ID) und zusätzlich eine Versionskennung. Lesbare Cursor (z. B. als Query-Parameter created_at=...&id=...) funktionieren auch, werden aber leichter falsch zusammengesetzt oder per Copy/Paste verfälscht. Opaque Tokens erlauben außerdem eine kontrollierte Weiterentwicklung.
Stabile Sortierung und Datenbank-Queries ohne Überraschungen
Sortierregeln: Eindeutig, konsistent, dokumentiert
Cursor-Pagination funktioniert nur so gut wie die Sortierdefinition. Eine robuste Regel ist: primär nach einem monotonic-like Feld (oft created_at), sekundär nach einer eindeutigen ID. Dabei muss die API klar festlegen, ob auf- oder absteigend sortiert wird und ob Filter die Sortierreihenfolge beeinflussen.
In der API-Doku sollte außerdem stehen, dass der Cursor an genau diese Sortierung gebunden ist. Wird die Sortierung serverseitig geändert, müssen alte Cursor kontrolliert behandelt werden (z. B. als ungültig markieren oder per Version migrieren).
SQL-Beispiel: „Seek“ mit zusammengesetztem Schlüssel
Angenommen, es wird absteigend nach created_at und id sortiert. Dann lautet das Muster:
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit
Viele Datenbanken unterstützen Tupelvergleiche; wenn nicht, lässt sich das logisch äquivalent formulieren:
WHERE created_at < :t
OR (created_at = :t AND id < :id)
Damit wird genau „alles nach dem letzten Element“ geliefert. Keine Offsets, keine Seitenzahlen, und die Abfrage kann indexfreundlich bleiben.
Indexierung: ohne passenden Index wird auch Cursor langsam
Für die oben genannte Abfrage braucht es typischerweise einen zusammengesetzten Index, der die Sortierung abbildet. Beispiel: Index auf (created_at DESC, id DESC) oder in aufsteigender Reihenfolge, abhängig von Datenbank und Optimizer. Die Kernidee: Filter + Sortierung müssen möglichst „aus dem Index“ bedient werden, sonst entstehen Sort-Operationen oder breite Scans.
Wenn zusätzliche Filter existieren (z. B. status oder tenant_id), ist oft ein weiterer zusammengesetzter Index nötig, der mit dem häufigsten Zugriffsmuster harmoniert. Hier lohnt ein Blick auf reale Query-Pläne im Staging – nicht auf Vermutungen.
Cursor-Design: Token-Versionierung, Validierung, Sicherheit
Welche Daten gehören in den Cursor?
Minimal müssen alle Werte enthalten sein, die die Position in der Sortierung bestimmen: z. B. created_at und id. Optional können Filterparameter aufgenommen werden, wenn sichergestellt werden soll, dass Cursor nicht mit anderen Filtern wiederverwendet werden. Das ist vor allem bei Multi-Tenant-APIs sinnvoll: Cursor sollten nicht zwischen Mandanten „umherschweifen“ können.
Praktisch ist eine kleine Struktur wie:
- Version (z. B.
v=1) - Sort-Key 1 (z. B. Zeitstempel)
- Sort-Key 2 (z. B. ID)
- Optional: Hash/Signatur über relevante Parameter
Cursor validieren und Fehlerszenarien sauber behandeln
Ein Cursor ist Eingabe – und kann kaputt sein. Gute APIs reagieren mit klaren 4xx-Fehlern (z. B. „invalid_cursor“), nicht mit 500. Außerdem sollte die Implementierung robust gegen:
- falsches Format (kein gültiges Base64/JSON)
- nicht parsebare Datumswerte
- Cursor-Version unbekannt
- Cursor passt nicht zu aktuellen Query-Parametern
In Systemen mit strikter API-Validierung passt thematisch der Ansatz aus Schema-Validation für APIs: Eingaben früh ablehnen, bevor Query-Building oder Datenbankzugriff starten.
Manipulationsschutz: Cursor nicht als Trust Boundary behandeln
Opaque Cursor werden oft einfach base64-kodiert. Das ist keine Sicherheit. Wenn Cursor sensible Informationen enthalten (z. B. interne IDs, Tenant-IDs) oder wenn Missbrauch teuer werden kann (z. B. Cursor zeigt absichtlich weit zurück, um Last zu erzeugen), braucht es Integritätsschutz, etwa durch eine serverseitige Signatur (HMAC) über die Cursor-Payload. Alternativ kann der Cursor rein serverseitig gespeichert werden (z. B. kurzlebig), was aber Betriebsaufwand und State erzeugt.
Praxisdetails: Filter, bidirektionales Blättern und Konsistenz
Filter kombinieren: Cursor muss zum Filter passen
Ein Klassiker: Erst Seite 1 ohne Filter laden, dann Seite 2 mit Filter (oder umgekehrt) – und der Cursor wird trotzdem wiederverwendet. Die Antwort ist dann unvorhersehbar. Sauber ist: Cursor entweder an den vollständigen Query-Kontext binden (Filterwerte in Cursor integrieren und signieren) oder die API verlangt, dass der Cursor nur mit identischen Parametern genutzt werden darf und verweigert sonst die Anfrage.
Vorwärts und rückwärts: zwei Cursor statt „page=-1“
Für UIs mit „Zurück“-Navigation kann die API neben next_cursor auch prev_cursor liefern. Rückwärts-Pagination ist nicht trivial, weil die Sortierung invertiert und die Ergebnismenge danach wieder umgedreht werden muss. Implementierungen scheitern oft an Details wie: „vorherige Seite“ soll genau die vorher gesehenen Elemente liefern, nicht nur „irgendwelche davor“.
Konsistenzanspruch klären: Snapshot vs. „best effort“
Cursor-Pagination liefert stabile Reihenfolge relativ zur Sortierung, aber keinen vollständigen Snapshot über alle Seiten. Wenn während des Scrollens Datensätze ändern, kann das zu „verschobenen“ Positionen führen, je nachdem ob Updates sortierrelevant sind. Für echte Snapshots braucht es meist zusätzliche Mechanismen (z. B. eine feste Sicht über eine Revision/Export-ID oder eine Transaktions-Snapshot-Strategie), was die Komplexität deutlich erhöht.
Umsetzungsschritte, die in Teams funktionieren
Konkrete Reihenfolge für eine saubere Einführung
- Sortierung festlegen und total ordnen (primär + Tie-Breaker), inklusive Dokumentation im API-Vertrag.
- Query als Keyset-Abfrage implementieren und mit realistischen Filtern testen.
- Passende Indizes anlegen und Query-Pläne prüfen, bevor Traffic umgestellt wird.
- Cursor-Format versionieren und Validierung zentral implementieren (z. B. im Request-Layer).
- Fehlercodes und Edge-Cases definieren (invalid_cursor, cursor_mismatch, limit_out_of_range).
- Client-Verhalten prüfen: Caching, Retry-Logik und parallele Requests (z. B. schnelles Scrollen).
Typische Stolpersteine aus realen Backends
Limit zu groß, Payload zu schwer: Pagination ist kein Export
Pagination wird häufig zweckentfremdet, um „alle Daten“ zu ziehen. Das führt zu übergroßen Limits, langen Response-Zeiten und unnötiger Last. Hier hilft eine klare Obergrenze für limit und ein separater Export-Mechanismus (asynchron, mit Job-Tracking). Für die Entkopplung solcher Hintergrundprozesse passt konzeptionell Async Jobs im Backend.
Timeouts und Retries: Pagination verstärkt langsame Endpunkte
Wenn jede Seite langsam ist, multipliziert sich das Problem: UIs feuern mehrere Requests, Clients retryen bei Timeouts, und der Endpunkt wird zum Hotspot. Damit Pagination nicht zum Verstärker wird, lohnt sauberes Timeout-Handling und das Prüfen von Worst-Case-Latenzen. Dazu passt die Einordnung aus Request-Timeouts im Backend.
Tests: Korrektheit braucht Daten, die sich währenddessen ändern
Unit-Tests auf Query-Builder-Ebene sind wichtig, aber nicht ausreichend. Für Pagination sollten Integrationstests bewusst parallele Inserts/Updates simulieren: Seite 1 laden, dazwischen Inserts, Seite 2 laden. Erwartung: keine Duplikate, keine Lücken bezogen auf die Sortierlogik; Cursor-Invalidierung sauber; deterministische Reihenfolge bei gleichen Timestamps dank Tie-Breaker. In Multi-Tenant-Systemen zusätzlich testen, dass Cursor nicht tenant-übergreifend akzeptiert werden.
Wann Offset trotzdem okay ist
Statische Daten und kleine Datenmengen
Offset/Limit kann ausreichend sein, wenn die Daten praktisch unverändert sind (z. B. historisierte Tabellen), die Offsets klein bleiben und es keine hohen Konsistenzanforderungen gibt. Auch für Admin-Tools mit niedriger Nutzung kann das vertretbar sein. Wichtig ist dann trotzdem: deterministische Sortierung und eine klare Grenze, ab wann Offset zu teuer wird.
„Springe zu Seite 20“ als Produktanforderung
Cursor-Pagination ist schlecht darin, direkt „Seite N“ anzuspringen, weil es keine Seite gibt, nur Positionen. Wenn „Seite 20“ zwingend ist (z. B. bei bestimmten UX-Anforderungen), kann eine hybride Lösung nötig sein: etwa serverseitige Such-/Filterfunktionen, die das „Anspringen“ über einen anderen Schlüssel ermöglichen, oder ein sekundärer Index, der gezielt Positionen berechnet. Das sollte als Produktentscheidung betrachtet werden, nicht als reine Technikfrage.
Mehr Einordnung zu verwandten Backend-Themen findet sich unter Software & Entwicklung.
