Technisch im Detail implementieren: vom Offset-Problem zum stabilen Cursor-Token
Offset-Pagination mit OFFSET und LIMIT wirkt auf den ersten Blick simpel, liefert aber bei sich ändernden Datenmengen zwischen zwei Anfragen verschobene, doppelte oder übersprungene Einträge. Cursor-basierte Pagination (auch Keyset-Pagination genannt) löst dieses Problem strukturell, indem jede Seite an einen konkreten Datensatz statt an eine numerische Position gebunden wird, über ein opakes Token aus Sortierschlüssel und eindeutiger ID.
Inhaltsverzeichnis
- 1. Warum Offset-Pagination bei sich ändernden Daten unzuverlässig wird
- 2. Wie Cursor-Pagination das Problem strukturell löst
- 3. Implementierung mit Symfony und Doctrine: die Kernabfrage
- 4. Aufbau und Kodierung des Cursor-Tokens im Detail
- 5. Warum Cursor-Pagination bei großen Tabellen deutlich performanter ist
- 6. Warum die ID als Tie-Breaker unverzichtbar ist
- 7. Vorwärts und rückwärts blättern: bidirektionale Cursor
- 8. Grenzen und Nachteile der Cursor-Pagination
- 9. Wann Cursor- und wann Offset-Pagination die richtige Wahl ist
- 10. Zusammenfassung
- 11. FAQ
1. Warum Offset-Pagination bei sich ändernden Daten unzuverlässig wird
Bei den meisten REST-APIs beginnt die Pagination denkbar einfach: Ein Client fragt mit ?page=3&limit=20 oder ?offset=40&limit=20 nach einem Ausschnitt einer Liste, und die Datenbank liefert per OFFSET und LIMIT genau die passenden Zeilen zurück. Solange sich die zugrunde liegende Datenmenge zwischen zwei Anfragen nicht ändert, funktioniert dieses Muster zuverlässig und ist mit wenigen Zeilen SQL umgesetzt. Sobald jedoch parallel neue Datensätze eingefügt oder bestehende gelöscht werden, während ein Client noch durch die Ergebnisliste blättert, verschiebt sich die Position jeder nachfolgenden Seite relativ zur ursprünglichen Anfrage.
Ein konkretes Beispiel macht das Problem greifbar: Eine Liste von Bestellungen ist absteigend nach created_at sortiert, ein Nutzer lädt Seite 1 mit den zwanzig neuesten Einträgen. Kommt zwischen dem Laden von Seite 1 und Seite 2 eine neue Bestellung hinzu, verschiebt sich der gesamte Datensatz um eine Position nach hinten, und OFFSET 20 liefert nun einen Eintrag, der bereits auf Seite 1 sichtbar war, ein doppelter Eintrag in der UI. Wird stattdessen ein Eintrag gelöscht, kann umgekehrt ein Eintrag komplett übersprungen werden, ohne dass der Client das bemerkt. Für Listen mit hoher Schreibfrequenz, etwa Aktivitäts-Feeds oder Bestelllisten, ist dieses Verhalten kein Randfall, sondern die Regel.
2. Wie Cursor-Pagination das Problem strukturell löst
Cursor-Pagination verzichtet auf eine numerische Position (die n-te Zeile) und referenziert stattdessen einen konkreten Datensatz als Ausgangspunkt: gib mir die nächsten zwanzig Einträge nach diesem hier. Diese Referenz bleibt stabil, selbst wenn sich davor Zeilen ändern, weil sie sich nicht auf eine Position in der Ergebnismenge bezieht, sondern auf einen konkreten Wert im Sortierschlüssel. Das Verfahren wird deshalb auch als Keyset-Pagination bezeichnet, weil der Schlüssel und nicht die Position den Ausgangspunkt für die nächste Seite bildet.
In der Praxis wird dieser Ausgangspunkt als opakes Cursor-Token an den Client zurückgegeben: eine Zeichenkette, die der Client speichert und bei der nächsten Anfrage unverändert mitschickt, ohne ihren Inhalt zu kennen oder zu interpretieren. Serverseitig steckt hinter dem Token meist eine Kombination aus Sortierschlüssel (zum Beispiel created_at) und einer eindeutigen ID, als JSON kodiert und anschließend base64-kodiert, damit das Token als einzelner, URL-sicherer String übertragen werden kann. Diese Opazität ist bewusst gewählt: Der Client soll das Token niemals selbst konstruieren oder manipulieren, sondern es ausschließlich als Blackbox weiterreichen.
3. Implementierung mit Symfony und Doctrine: die Kernabfrage
Die konzeptuelle SQL-Bedingung für Keyset-Pagination lautet WHERE (sort_key, id) > (cursor_key, cursor_id), eine Tupel-Vergleichsoperation, die MySQL 8 und PostgreSQL nativ unterstützen. Da Doctrines QueryBuilder solche Zeilenwert-Vergleiche nicht direkt abbildet, wird die Bedingung praktisch als logische Disjunktion ausgedrückt: entweder liegt der Sortierschlüssel echt hinter dem Cursor-Wert, oder er ist gleich und die ID liegt dahinter. Beide Varianten liefern dasselbe Ergebnis, die OR-Formulierung ist aber portabler und in jeder Doctrine-Version ohne Zusatzaufwand nutzbar.
Wichtig ist, dass sowohl der Sortierschlüssel als auch die ID Teil desselben zusammengesetzten Index sind, sonst muss die Datenbank trotz der schlanken WHERE-Bedingung einen Großteil der Tabelle durchsuchen. Für die erste Seite existiert noch kein Cursor, in diesem Fall wird die WHERE-Bedingung schlicht weggelassen und die Query liefert die ersten n Zeilen in Sortierreihenfolge. Das folgende Repository zeigt die vollständige Implementierung inklusive Encoding und Decoding des Cursor-Tokens.
<?php
declare(strict_types=1);
final class ProductCursorRepository
{
public function __construct(private readonly Connection $connection)
{
}
/**
* Lädt die nächste Seite per Keyset-Pagination.
* Sortierschlüssel: created_at, Tie-Breaker: id (beide indiziert).
*/
public function findNextPage(?string $cursor, int $limit): array
{
$qb = $this->connection->createQueryBuilder()
->select('id', 'name', 'price', 'created_at')
->from('product')
->orderBy('created_at', 'ASC')
->addOrderBy('id', 'ASC')
->setMaxResults($limit + 1);
if ($cursor !== null) {
[$cursorCreatedAt, $cursorId] = $this->decodeCursor($cursor);
$qb->andWhere('(created_at > :createdAt) OR (created_at = :createdAt AND id > :id)')
->setParameter('createdAt', $cursorCreatedAt)
->setParameter('id', $cursorId);
}
return $qb->executeQuery()->fetchAllAssociative();
}
private function decodeCursor(string $cursor): array
{
$decoded = json_decode(base64_decode($cursor, true), true, flags: JSON_THROW_ON_ERROR);
if (!isset($decoded['created_at'], $decoded['id'])) {
throw new InvalidArgumentException('Cursor-Token ist fehlerhaft oder unvollständig.');
}
return [$decoded['created_at'], (int) $decoded['id']];
}
public function encodeCursor(string $createdAt, int $id): string
{
return base64_encode(json_encode(['created_at' => $createdAt, 'id' => $id], JSON_THROW_ON_ERROR));
}
}
4. Aufbau und Kodierung des Cursor-Tokens im Detail
Ein Cursor-Token enthält im Kern genau die Werte, die für die WHERE-Bedingung der nächsten Seite gebraucht werden: den Wert des Sortierschlüssels und die ID des letzten Elements der aktuellen Seite. Diese Werte werden als assoziatives Array in JSON serialisiert und anschließend base64-kodiert, damit ein beliebiges Datumsformat, Sonderzeichen oder Zahlen problemlos in einem einzigen URL-Parameter transportiert werden können, ohne zusätzliches Escaping.
Beim Decodieren auf Serverseite darf niemals blind vertraut werden, dass ein eingehendes Token gültig ist: Ein manipuliertes oder schlicht fehlerhaftes base64-Fragment muss zu einer sauberen 400-Antwort führen statt zu einer unbehandelten Exception. Ebenso wichtig ist eine Typprüfung der dekodierten Werte, denn ein Client könnte theoretisch ein syntaktisch gültiges, aber inhaltlich unpassendes Token senden, etwa mit einer ID als Zeichenkette statt als Zahl. Eine robuste Implementierung validiert deshalb explizit, dass die erwarteten Felder vorhanden und vom erwarteten Typ sind, bevor sie in die Query einfließen.
5. Warum Cursor-Pagination bei großen Tabellen deutlich performanter ist
Bei OFFSET n muss die Datenbank intern zunächst n Zeilen lesen (oder zumindest deren Positionen im Index durchlaufen) und verwerfen, bevor sie die eigentlich angeforderten Zeilen zurückgibt. Der Aufwand wächst dabei linear mit der Seitentiefe: Seite 1000 einer Liste mit OFFSET 20000 ist spürbar langsamer als Seite 1, selbst wenn beide Anfragen dieselbe Anzahl Zeilen zurückliefern. Bei Tabellen mit mehreren Millionen Zeilen wird dieser Effekt schnell zum sichtbaren Performance-Problem, gerade bei Endlos-Scroll-Oberflächen, die tief in eine Liste hineinblättern.
Die WHERE-Bedingung der Cursor-Pagination dagegen nutzt einen klassischen Index-Seek: Die Datenbank springt direkt an die passende Position im zusammengesetzten Index und liest von dort aus die nächsten n Zeilen, unabhängig davon, wie tief diese Position in der Gesamtliste liegt. Dadurch bleibt die Antwortzeit über alle Seiten hinweg annähernd konstant, ein entscheidender Vorteil für APIs mit großen, wachsenden Datenmengen, bei denen Nutzer regelmäßig tief in Listen blättern oder scrollen.
6. Warum die ID als Tie-Breaker unverzichtbar ist
Ein reiner Sortierschlüssel wie created_at ist in der Praxis selten wirklich eindeutig: Werden mehrere Datensätze innerhalb derselben Sekunde oder Millisekunde angelegt, etwa durch einen Batch-Import, teilen sie sich denselben Zeitstempel. Ohne einen zweiten, garantiert eindeutigen Vergleichswert kann die Datenbank bei gleichen Sortierschlüsseln nicht zuverlässig bestimmen, welche Zeilen bereits ausgeliefert wurden und welche noch folgen, was wieder zu übersprungenen oder doppelten Einträgen führt, dem exakt gleichen Problem, das Cursor-Pagination eigentlich lösen soll.
Die Lösung ist die Kombination aus Sortierschlüssel und einer eindeutigen ID als Tie-Breaker in Sortierung und WHERE-Bedingung gleichermaßen. Dadurch entsteht eine echte Totalordnung über die Ergebnismenge, jede Zeile hat eine eindeutige Position relativ zu jeder anderen Zeile. Voraussetzung dafür ist ein zusammengesetzter Index auf genau diesen beiden Spalten in genau dieser Reihenfolge, ohne diesen Index verpufft der Performance-Vorteil gegenüber Offset-Pagination fast vollständig.
7. Vorwärts und rückwärts blättern: bidirektionale Cursor
Eine vollständige Pagination-API braucht in der Regel nicht nur einen next_cursor, sondern auch die Möglichkeit, zur vorherigen Seite zurückzublättern. Praktisch bedeutet das, dass jede Antwort sowohl einen Cursor für die nächste als auch einen für die vorherige Seite mitliefert, zusammen mit den Flags hasNextPage und hasPreviousPage. Um hasNextPage zuverlässig zu bestimmen, ohne eine zusätzliche COUNT-Query, wird intern häufig ein Element mehr abgefragt als angezeigt wird (limit plus eins), dessen Vorhandensein signalisiert, dass eine weitere Seite existiert.
Für die Rückwärts-Navigation muss die Vergleichsrichtung in der WHERE-Bedingung sowie die Sortierrichtung umgekehrt werden, damit die Datenbank effektiv rückwärts durch den Index läuft. Das dabei entstehende Ergebnis liegt allerdings in umgekehrter Reihenfolge vor und muss vor der Rückgabe an den Client noch einmal gedreht werden, damit die Anzeigereihenfolge über beide Blätterrichtungen hinweg konsistent bleibt. Diese Logik lohnt sich, einmal zentral zu kapseln, statt sie in jedem Endpoint erneut zu implementieren.
8. Grenzen und Nachteile der Cursor-Pagination
Der offensichtlichste Nachteil ist der Verlust der direkten Sprungmöglichkeit zu einer beliebigen Seite: Mit Offset-Pagination lässt sich springe direkt zu Seite 47 trivial über OFFSET 940 umsetzen, mit Cursor-Pagination ist das strukturell nicht vorgesehen, weil jeder Cursor nur die unmittelbar nächste Position kennt. Für Benutzeroberflächen mit klassischer Seitenzahlen-Navigation (1, 2, 3, ... 47) ist Cursor-Pagination deshalb ungeeignet oder erfordert einen Kompromiss, etwa eine hybride Lösung nur für die ersten Seiten.
Auch die Anzeige einer exakten Gesamtzahl der Ergebnisse (Total Count) wird durch Cursor-Pagination nicht gelöst, denn eine vollständige COUNT-Abfrage über eine sehr große Tabelle bleibt unabhängig vom Pagination-Verfahren teuer. Wird ein exakter Gesamtwert zwingend benötigt, braucht es dafür eine separate Strategie, etwa einen gecachten, periodisch aktualisierten Zählerwert oder eine bewusst ungefähre Schätzung, statt bei jeder Anfrage live über die gesamte Tabelle zu zählen.
9. Wann Cursor- und wann Offset-Pagination die richtige Wahl ist
Die Entscheidung zwischen beiden Verfahren lässt sich anhand weniger Kriterien treffen: wie volatil die zugrunde liegenden Daten sind, wie groß die Tabelle ist, und ob die Benutzeroberfläche eine klassische Seitenzahlen-Navigation oder eher Infinite Scroll beziehungsweise Feed-artiges Nachladen benötigt. Die folgende Tabelle fasst die wichtigsten Punkte für eine schnelle Entscheidung im konkreten Projekt zusammen.
| Kriterium | Offset-Pagination | Cursor-Pagination | Empfehlung |
|---|---|---|---|
| Konsistenz bei sich ändernden Daten | Anfällig für Duplikate/Lücken | Stabil, keine Verschiebung | Cursor bei volatilen Listen |
| Sprung zu beliebiger Seite | Möglich (OFFSET n) | Nicht möglich | Offset bei Seitenzahl-Navigation |
| Performance bei großen Tabellen | Sinkt mit wachsendem Offset | Konstant durch Index-Seek | Cursor ab hoher Datenmenge |
| Implementierungsaufwand | Gering | Höher (Encoding, Tie-Breaker) | Offset für kleine, statische Listen |
| Eignung für Infinite Scroll | Suboptimal | Ideal | Cursor für Feed/Scroll-UIs |
Mironsoft
OpenAPI-Design, Symfony-APIs und API-Sicherheit
APIs, die externe Teams ohne Rückfragen integrieren können?
Wir prüfen bestehende REST-APIs auf inkonsistente Fehlerformate, fehlende OpenAPI-Dokumentation und Sicherheitslücken und bauen daraus eine API, die klar dokumentiert, versioniert und gegen Missbrauch abgesichert ist.
API-Review
OpenAPI-Spezifikation, Fehlerformate und Statuscodes auf Konsistenz prüfen.
Symfony-Umsetzung
DTOs, Serializer und Validator für saubere, typsichere Request/Response-Modelle einsetzen.
Security-Audit
Rate-Limiting, Auth-Schemes und Input-Validierung gegen echte Angriffsflächen absichern.
10. Zusammenfassung
Cursor-basierte Pagination: Das Wichtigste auf einen Blick
Kernproblem
Offset-Pagination liefert bei parallelen Schreibvorgängen verschobene, doppelte oder übersprungene Einträge, weil sie auf einer numerischen Position statt einem konkreten Datensatz basiert.
Lösung
Ein opakes Cursor-Token aus Sortierschlüssel und eindeutiger ID referenziert einen konkreten Datensatz als stabilen Ausgangspunkt für die nächste Seite.
Performance
Die WHERE-Bedingung nutzt einen Index-Seek statt eines wachsenden OFFSET-Scans, die Antwortzeit bleibt über alle Seiten hinweg annähernd konstant.
Praktischer Rat
Cursor-Pagination für volatile, große Listen und Infinite Scroll einsetzen, Offset-Pagination nur für kleine, stabile Listen mit Seitenzahlen-Navigation beibehalten.