Cursor Pagination ist eine Methode zum seitenweisen Laden von Daten, die einen eindeutigen Cursor zur Navigation durch einen geordneten Datensatz verwendet. Laut der GraphQL-Spezifikation (2025) ist die cursor-basierte Paginierung der empfohlene Standard für APIs, die mit dynamischen Daten arbeiten. Cursor pagination beseitigt die Hauptnachteile des Offset-Ansatzes: Instabilität bei Einfügungen und Leistungsabfall bei großen Offsets.
Wichtige Punkte
Cursor Pagination ist eine seitenweise Lademethode, bei der der Server zusammen mit den Daten einen speziellen Zeiger — einen Cursor — zurückgibt. Der Client verwendet diesen Cursor in der nächsten Anfrage, um den nächsten Batch von Datensätzen abzurufen. Der Cursor ist ein eindeutiger Identifikator des letzten Elements der aktuellen Seite.
Im Gegensatz zur Offset-Paginierung, bei der der Client sagt: „Gib mir Seite 5 mit 20 Datensätzen,“ funktioniert die Cursor-Paginierung anders: „Gib mir 20 Datensätze nach dem Datensatz mit ID = 83.“ Der Server führt eine Abfrage mit WHERE id > 83 und LIMIT 20 aus. Dieser Ansatz garantiert, dass jeder Datensatz unabhängig von Einfügungen genau in eine Seite fällt.
Das Konzept der Cursor-Paginierung wurde dank der Relay Connection (GraphQL)-Spezifikation weit verbreitet übernommen, die die cursor-basierte Paginierung zum Standard für moderne APIs gemacht hat. Relay definiert das Antwortformat: edges (Array von Datensätzen mit Cursorn), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Die Cursor-Paginierung ist keine neue Technik — sie wurde lange vor dem Web in Datenbanken verwendet. In SQL nennt man sie keyset pagination oder seek method. Die Methode wurde in APIs nach der Veröffentlichung der Relay-Spezifikation im Jahr 2015 populär, die das Cursor-Format als base64-kodierten String zur Vereinheitlichung über HTTP-Transport formalisierte.
Das Grundprinzip der Cursor-Paginierung ist, dass die Abfrage für die Positionierung eine WHERE-Bedingung auf einem indizierten Feld verwendet, nicht einen Offset. Für die Vorwärtsrichtung wird WHERE id > last_id verwendet, für die Rückwärtsrichtung WHERE id < first_id. Der B-tree-Index findet den ersten Datensatz nach dem Cursor in O(log n) und bietet so eine stabile Antwortzeit.
-- 20 Datensätze nach dem Cursor '83' abrufen
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- 20 Datensätze VOR dem Cursor '83' abrufen (zurück)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Ein Cursor kann einfach (ein ID-Wert) oder komplex (aus mehreren Feldern zusammengesetzt) sein. Einfache Cursor sind der Primärschlüssel eines Datensatzes, z.B. Auto-Increment-ID oder UUID. Zusammengesetzte Cursor werden zum Sortieren nach nicht-eindeutigen Feldern verwendet, z.B. (created_at, id), wobei id die Eindeutigkeit bei identischen Zeitstempeln garantiert.
Ein typisches API-Format ist ein Cursor als base64-kodierter String. Der Server dekodiert den Cursor, extrahiert den Wert und erstellt die SQL-Abfrage. Base64-Kodierung verbirgt die interne Cursor-Struktur vor dem Client und ermöglicht eine Formatänderung ohne Unterbrechung der Abwärtskompatibilität. Der Client erhält die Cursor im endCursor-Feld der Antwort und übergibt sie als String in der nächsten Anfrage.
Die Cursor-Paginierung unterstützt bidirektionale Navigation. Für die Vorwärtsbewegung (next) wird der Cursor des letzten Elements der aktuellen Seite verwendet, für die Rückwärtsbewegung (previous) der Cursor des ersten Elements. Die Parameter after und before in der Anfrage bestimmen die Richtung: after holt Datensätze nach dem Cursor, before holt Datensätze vor dem Cursor.
Die Wahl zwischen Cursor- und Offset-Paginierung ist eine der wichtigsten architektonischen Entscheidungen beim Entwurf einer API. Jede Methode hat Stärken und Schwächen, die ihre Anwendbarkeit bestimmen. Cursor pagination gewinnt in Szenarien mit dynamischen Daten; Offset gewinnt in Szenarien mit willkürlicher Navigation.
| Eigenschaft | Cursor | Offset |
|---|---|---|
| Stabilität bei Einfügungen | Hoch (keine Duplikate) | Niedrig (Seitenverschiebung) |
| Leistung bei großen Datenmengen | O(log n) — stabil | O(n) — nimmt mit Wachstum ab |
| Navigation nach Seitennummer | Nein | Ja (page=5) |
| Implementierungskomplexität | Mittel | Niedrig |
| REST-Unterstützung | cursor/before/after | page/offset |
| GraphQL-Unterstützung | Relay-Standard | Nicht empfohlen |
Die Offset-Paginierung führt einen vollständigen Tabellenscan bis zur OFFSET-Position durch. Bei offset=100000 liest und überspringt die Datenbank 100000 Zeilen, selbst wenn LIMIT 20 ist. MySQL und PostgreSQL können OFFSET nicht optimieren — dies ist eine Implementierungseigenschaft von LIMIT/OFFSET in SQL. Die Cursor-Paginierung verwendet einen B-tree-Index, der die Position in O(log n) findet.
Ein weiteres Offset-Problem ist das „Überspringen“ von Datensätzen beim Rückwärtspaginieren. Wenn ein Benutzer Seite 5 geladen hat und in diesem Moment neue Datensätze hinzugefügt werden, sieht er bei der Anforderung von Seite 6 entweder den Datensatz von Seite 5 erneut oder verpasst die neuen. Cursor pagination eliminiert dieses Szenario vollständig: Der Cursor zeigt auf eine bestimmte Stelle im Set, und Einfügungen ändern die Position nicht.
Betrachten wir die Implementierung der Cursor-Paginierung im Backend (Kotlin + Spring) und im Client (Android + Retrofit). Der Server akzeptiert die Parameter after, before, limit und gibt eine Liste von Datensätzen mit Cursorn und pageInfo zurück. Eine typische Antwort enthält hasNextPage und hasPreviousPage zur Verwaltung der Paginierungs-UI.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Im Client wird die Cursor-Paginierung über PagingSource aus Paging 3 implementiert, wobei der Schlüssel ein Cursor (Long) ist. PagingSource.load erhält LoadParams.key — den Cursor des zuletzt geladenen Datensatzes. LoadResult.Page gibt die Daten und nextKey — den Cursor für die nächste Seite — zurück. Wenn nextKey = null ist, ist die Paginierung abgeschlossen.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
In GraphQL wird die Cursor-Paginierung über das Relay Connection-Muster implementiert. Jeder Typ hat eine Connection (mit pageInfo und edges) und einen Edge (node + cursor). Die Abfrage übergibt die Parameter first, after, last, before. Der Server gibt ein Array von Edges mit Cursorn und pageInfo mit hasNextPage/hasPreviousPage zurück.
Die Cursor-Paginierung wird für APIs empfohlen, die mit dynamischen Daten arbeiten, bei denen Datensätze häufig hinzugefügt oder gelöscht werden. Klassische Beispiele: Nachrichtenfeed in sozialen Netzwerken, Chat-Nachrichten, Transaktionsverlauf, Kommentare zu Beiträgen. In all diesen Szenarien sind Konsistenz und das Fehlen von Duplikaten wichtig.
Es gibt Szenarien, in denen die Offset-Paginierung bequemer ist: Admin-Panels, bei denen eine Navigation nach Seitennummer erforderlich ist; Suche mit Paginierung, bei der sich Ergebnisse ändern können; Berichte und Analysen, bei denen ein fester Link zu Seite 5 benötigt wird. In diesen Fällen überwiegen die Vorteile des Cursors nicht die Implementierungskomplexität.
Die Cursor-Paginierung unterstützt kein „Springen“ zu einer beliebigen Seite — der Benutzer kann nicht auf „Seite 5“ klicken und dorthin gelangen. Dies ist eine architektonische Einschränkung: das Zählen der Gesamtzahl der Seiten erfordert eine separate COUNT-Abfrage, die für große Tabellen teuer sein kann. In solchen Fällen ein hybrider Ansatz: Cursor für Daten + Count für die Paginierung.
Häufig gestellte Fragen
Ein Cursor ist ein eindeutiger Datensatzidentifikator, der auf eine Position im Datensatz zeigt. Er kann einfach (eine Datensatz-ID) oder zusammengesetzt (mehrere Felder) sein. Der Client erhält den Cursor des letzten Datensatzes der Seite und übergibt ihn in der nächsten Anfrage, um den nächsten Batch zu erhalten.
Cursor pagination unterliegt keiner Verschiebung beim Hinzufügen neuer Datensätze — jedes Element fällt genau in eine Seite. Es behält auch die Geschwindigkeit bei großen Volumina bei, indem es Indizes anstelle des Scannens der ersten n Zeilen verwendet. Offset ist einfacher, aber instabil für dynamische Daten.
Ja, die Cursor-Paginierung ist nicht an GraphQL gebunden. Sie kann in jeder REST-API implementiert werden, indem der Cursor als Abfrageparameter ?after=83&limit=20 übergeben wird. Die Antwort sollte pageInfo mit endCursor und hasNextPage enthalten — dies ermöglicht dem Client, das Laden zu verwalten, ohne die interne Cursor-Struktur zu kennen.
Auto-Increment-ID ist die optimale Wahl: monoton steigend, ändert sich nicht, wird effizient indiziert. UUID v7 (zeitgeordnet) funktioniert ebenfalls. Timestamps können bei gleicher Zeit Duplikate erzeugen, daher kombinieren Sie es mit ID: (created_at, id) zur Gewährleistung der Cursor-Eindeutigkeit.
Die Cursor-Paginierung liefert keine Gesamtzahl der Seiten — dies ist ihre Einschränkung. Wenn Sie Gesamtinformationen benötigen, führen Sie eine separate COUNT-Abfrage mit denselben Filtern durch. Verwenden Sie für große Tabellen eine ungefähre Zählung via EXPLAIN oder einen zwischengespeicherten Gesamtwert aus Analysen.
Zusammenfassung
Wir entwickeln eine mobile Applikation schlüsselfertig
IT Sectr entwickelt seit 2017 iOS- und Android-Apps für Startups und Unternehmen. Wir beraten Sie und schlagen die beste Lösung vor.
Lesen Sie auch