GraphQL-Paginierung: Cursor, Connections und das Relay-Muster
Warum offset/limit bei bewegten Listen Duplikate und Lücken produziert und wie cursor-basierte Paginierung mit dem Connections-Muster stabil bleibt – inklusive Schema, Resolver-Skizze und den Fallstricken aus der Schulungspraxis.
Wenn ich in einer GraphQL-Schulung beim Thema Paginierung ankomme, ist die Stimmung im Raum meistens entspannt. Paginierung – das kennt doch jeder. Man nimmt ein limit und ein offset, klickt sich durch die Seiten, fertig. Genau an dieser Stelle stelle ich gern eine unbequeme Frage: Was passiert eigentlich, wenn jemand zwischen zwei Seitenaufrufen einen neuen Eintrag anlegt? Kurze Stille. Und dann fängt der interessante Teil an.
Denn Paginierung ist einer der Punkte, an denen sich zeigt, ob eine API robust ist oder nur im Demo-Fall funktioniert. Solange die Liste stillsteht, tut es offset/limit tadellos. Sobald sich die Liste unter dem blätternden Client verändert – und das ist der Normalfall, nicht die Ausnahme – bekommt man Probleme, die schwer zu reproduzieren und noch schwerer zu erklären sind. Genau deshalb hat die GraphQL-Community mit dem Connections-Muster eine eigene Konvention etabliert. Darum geht es hier.
Warum offset/limit bei bewegten Listen versagt
Machen wir das Problem konkret. Ich habe eine nach Erstelldatum absteigend sortierte Liste und blättere sie in Zweierschritten durch. Der Client fragt die erste Seite an: offset: 0, limit: 2. Er bekommt die Einträge an den Positionen 0 und 1. Für die nächste Seite fragt er offset: 2, limit: 2.
Das Tückische: offset ist eine reine Positionszählung. Es sagt "überspringe die ersten zwei Elemente" – aber es hat keine Ahnung, welche zwei Elemente das beim ersten Aufruf waren. Wenn zwischen den beiden Anfragen vorne ein neuer Eintrag eingefügt wird, rutscht die ganze Liste um eine Position nach hinten. Das Element, das eben noch auf Position 1 lag, liegt jetzt auf Position 2 – und wird dem Client bei offset: 2 ein zweites Mal ausgeliefert. Ein Duplikat.
Der umgekehrte Fall ist genauso ärgerlich: Wird vorne ein Eintrag gelöscht, rückt alles eine Position nach vorne. Das Element, das auf Position 2 lag, springt auf Position 1 – und wird bei offset: 2 übersprungen. Eine Lücke. Der Nutzer sieht einen Eintrag nie, obwohl er existiert.
flowchart TB
subgraph stabil["Stabil: cursor-basiert"]
direction LR
a1["Anfrage 1<br/>first: 2"] --> a2["liefert A, B<br/>endCursor = B"]
a2 --> a3["Anfrage 2<br/>after: Cursor von B"]
a3 --> a4["liefert C, D<br/>kein Duplikat"]
end
subgraph kaputt["Verschoben: offset/limit"]
direction LR
b1["Anfrage 1<br/>offset 0, limit 2"] --> b2["liefert A, B"]
b2 --> b3["neuer Eintrag X<br/>wird vorne eingefügt"]
b3 --> b4["Anfrage 2<br/>offset 2, limit 2"]
b4 --> b5["liefert B, C<br/>B doppelt"]
end
Der Kern ist immer derselbe: Offset zählt Positionen, und Positionen sind kein stabiler Anker, sobald sich die Liste vorne verändert. In der Schulung sage ich dazu gern: Offset ist wie eine Ansage im Zug "steigen Sie am dritten Halt aus" – wunderbar, solange niemand einen Zwischenhalt einlegt.
Der cursor-basierte Ausweg
Die Lösung ist ein Wechsel der Frage. Statt "gib mir die Elemente ab Position N" fragt der Client "gib mir die Elemente nach diesem konkreten Element". Dieses "diesem konkreten Element" wird durch einen Cursor repräsentiert – einen opaken Zeiger, den der Server ausgibt und den der Client unverändert zurückreicht.
Der entscheidende Unterschied: Ein Cursor ist an ein Element beziehungsweise dessen Sortierwert gebunden, nicht an eine Position. Wenn der Client sagt "weiter nach dem Element mit Cursor X", dann bleibt diese Aussage korrekt, egal wie viele Einträge davor eingefügt oder gelöscht werden. Das Fenster verschiebt sich nicht, weil es nicht an einer Zählung hängt, sondern an einem Anker in den Daten.
Wichtig, und da hake ich in Schulungen immer nach: Der Cursor ist opak. Der Client darf keine Struktur annehmen und keine Arithmetik damit treiben. Er darf ihn nur wieder zurückgeben. Das ist bewusst so – der Server bestimmt allein, was ein Cursor bedeutet. Heute mag darin eine ID stecken, morgen eine Kombination aus Sortierfeld und ID, übermorgen etwas völlig anderes. Solange der Client den Cursor nur durchreicht, kann der Server seine Cursor-Implementierung wechseln, ohne dass ein einziger Client kaputtgeht. Üblich ist eine Base64-Kodierung – aber das ist Konvention, keine Vorschrift, und dient nur dazu, dass niemand in Versuchung gerät, den Inhalt zu interpretieren.
Das Connections-Muster: edges, node, pageInfo
Damit sind wir beim eigentlichen Thema. Die GraphQL-Community hat rund um cursor-basierte Paginierung eine Konvention formalisiert: die Relay Cursor Connections Specification. Man muss Relay als Framework nicht einsetzen, um von dem Muster zu profitieren – es hat sich als De-facto-Standard etabliert, weil es eine wichtige Frage sauber löst: Wo hänge ich eigentlich den Cursor hin?
Die naive Antwort wäre, den Cursor direkt an den Datensatz zu kleben. Aber der Cursor ist keine Eigenschaft des Nutzers oder des Artikels – er ist eine Eigenschaft dieser Position in dieser Liste. Deshalb führt das Muster eine Zwischenschicht ein, die Edge. Eine Edge verbindet den eigentlichen Datensatz (node) mit seinem cursor. Der node bleibt sauber, die listenspezifische Information sitzt auf der Kante.
Darüber liegt die Connection. Sie bündelt die Liste der Edges und ein Objekt namens pageInfo, das die Metainformationen fürs Weiterblättern trägt.
Was liefern die einzelnen Bausteine konkret?
edgesist die Liste der Kanten. Jede Edge enthältnode(den reinen Datensatz, ohne List-Wrapper) undcursor(den opaken Zeiger auf genau diese Edge).pageInfo.hasNextPagesagt, ob es nach der aktuellen Seite noch weitere Elemente gibt. Das ist die maßgebliche Abbruchbedingung fürs Blättern.pageInfo.hasPreviousPageist das Gegenstück für die Rückwärtsrichtung.pageInfo.endCursorist der Cursor des letzten Elements der aktuellen Seite – genau der Wert, den der Client alsafterin die Folgeanfrage steckt.pageInfo.startCursorist das Pendant am Anfang der Seite, relevant fürs Rückwärtsblättern mitbefore.
Zur Namenskonvention: Der Connection-Typ endet auf Connection, der Edge-Typ auf Edge. pageInfo ist non-null, ebenso die beiden Booleans in PageInfo. Die beiden Cursor-Felder startCursor und endCursor dagegen sind nullable – bei einer leeren Seite gibt es schlicht keinen Cursor.
Ein oft gesehenes Feld ist totalCount, also die Gesamtzahl der Treffer. Das ist praktisch, aber ich weise in Schulungen ausdrücklich darauf hin: totalCount ist nicht Teil der Connections-Spezifikation. Es ist eine verbreitete optionale Erweiterung, und je nach Datenquelle kann ein exaktes COUNT teuer sein. Man nimmt es bewusst dazu, nicht als selbstverständlichen Standard.
Ein Beispiel-Schema
Genug Prosa, schauen wir auf SDL. Eine Query, die Nutzer paginiert ausliefert, sieht im Connections-Muster so aus:
type Query {
users(first: Int, after: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type User {
id: ID!
name: String!
createdAt: String!
}
Die Argumente folgen der Spezifikation. Für Vorwärts-Paginierung gibt es first: Int (die gewünschte Seitengröße, nicht-negativ) und after: String (der Cursor, hinter dem es weitergehen soll). Für die Rückwärtsrichtung gibt es analog last: Int und before: String. first und last gleichzeitig zu senden ist laut Spezifikation ausdrücklich unerwünscht – das Ergebnis wäre mehrdeutig.
Eine Abfrage vom Client liest sich dann so:
query FirstPage {
users(first: 2) {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Und die Folgeanfrage nimmt den endCursor der vorigen Antwort als after:
query NextPage {
users(first: 2, after: "dXNlcjpjdXJzb3I6NDI=") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Ich erwähne an dieser Stelle bewusst, dass viele Einführungsbeispiele – auch mein eigenes Grundlagenmaterial – vereinfachte Varianten zeigen, etwa skip/take oder ein after: 99, bei dem der Cursor verdächtig nach einer ID oder einem Offset aussieht. Das ist didaktisch praktisch, aber es ist nicht das echte Relay-Muster. In der Praxis ist der Cursor opak und sieht eben nicht nach einer lesbaren Zahl aus. Diese Unterscheidung klarzumachen, ist mir wichtig, damit niemand später versucht, auf dem Client mit dem Cursor zu rechnen.
Die Resolver-Skizze
Auf der Serverseite muss der Resolver drei Dinge tun: den eingehenden Cursor dekodieren, die passende Datenmenge laden und daraus Edges plus pageInfo bauen. Der spannendste Trick steckt in der Ermittlung von hasNextPage.
Ein häufiger Anfängerfehler ist, hasNextPage daraus abzuleiten, ob das Ergebnis kleiner als first ist. Das ist unzuverlässig: Wenn zufällig genau first Elemente übrig sind, wüsste man nicht, ob danach noch etwas kommt. Der saubere Weg ist, ein Element mehr zu laden, als angefragt wurde – first + 1. Existiert dieses Extra-Element, gibt es eine nächste Seite. Anschließend kürzt man das Ergebnis wieder auf first.
function encodeCursor(id) {
return Buffer.from(`cursor:${id}`).toString("base64");
}
function decodeCursor(cursor) {
const decoded = Buffer.from(cursor, "base64").toString("utf8");
return Number(decoded.split(":")[1]);
}
async function usersResolver(_parent, args, context) {
const { first = 20, after } = args;
const limit = Math.min(first, 100); // enforce an upper bound
const afterId = after ? decodeCursor(after) : 0;
// load one extra row to detect a following page
const rows = await context.db.query(
`SELECT id, name, created_at
FROM users
WHERE id > $1
ORDER BY id ASC
LIMIT $2`,
[afterId, limit + 1]
);
const hasNextPage = rows.length > limit;
const pageRows = hasNextPage ? rows.slice(0, limit) : rows;
const edges = pageRows.map((row) => ({
node: row,
cursor: encodeCursor(row.id),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: Boolean(after),
startCursor: edges.length ? edges[0].cursor : null,
endCursor: edges.length ? edges[edges.length - 1].cursor : null,
},
};
}
Zwei Details lohnen den zweiten Blick. Erstens die Obergrenze: Ohne ein Math.min könnte ein Client first: 1000000 senden und die Datenbank in die Knie zwingen. Eine erzwungene Obergrenze ist Pflicht, nicht Kür. Zweitens die Cursor-Helfer encodeCursor/decodeCursor – sie demonstrieren, dass hinter dem opaken String hier schlicht eine ID steckt, aber der Client das eben nicht wissen muss.
Der Ablauf über mehrere Seiten sieht als Dialog zwischen Client und Server so aus:
sequenceDiagram participant C as Client participant S as Server C->>S: users(first: 2) S-->>C: edges A, B<br/>pageInfo endCursor=B, hasNextPage=true C->>S: users(first: 2, after: Cursor B) S-->>C: edges C, D<br/>pageInfo endCursor=D, hasNextPage=true C->>S: users(first: 2, after: Cursor D) S-->>C: edges E<br/>pageInfo endCursor=E, hasNextPage=false Note over C,S: Schleife endet, sobald hasNextPage=false
Der Client blättert also in einer simplen Schleife: Solange hasNextPage wahr ist, schickt er die nächste Anfrage mit dem zuletzt erhaltenen endCursor als after. Kein Positionszählen, kein Nachrechnen.
Der Preis der Sortierung – und ein Datenbank-Bonus
Ein Punkt, an dem cursor-basierte Paginierung stillschweigend scheitert, ist eine mehrdeutige Sortierung. Der Cursor braucht eine deterministische, eindeutige Ordnung. Sortiere ich etwa nur nach created_at, und zwei Datensätze tragen denselben Zeitstempel, dann ist an dieser Gleichstands-Stelle nicht definiert, welcher zuerst kommt – und genau dort entstehen wieder Duplikate oder Auslassungen. Die Lösung ist ein Tie-Breaker: Man sortiert zusätzlich nach einer garantiert eindeutigen Spalte, in der Regel der ID. Aus ORDER BY created_at wird ORDER BY created_at, id.
Damit fällt ein hübscher Nebeneffekt ab. Cursor-Paginierung lässt sich in SQL als sogenannte Keyset-Pagination formulieren, und die skaliert deutlich besser als große Offsets:
SELECT id, created_at, name
FROM users
WHERE (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at, id
LIMIT :page_size;
Der Grund: Ein großes OFFSET n zwingt die Datenbank, n Zeilen zu lesen und wegzuwerfen, bevor sie überhaupt anfängt zu liefern – bei tiefen Seiten wird das spürbar langsam. Die Keyset-Variante mit WHERE (sort_key, id) > (...) kann dagegen direkt über den Index an die richtige Stelle springen. Cursor-Paginierung ist also nicht nur korrekter bei bewegten Listen, sie ist bei tiefem Blättern oft auch schneller.
Wann offset trotzdem in Ordnung ist
Nach so viel Kritik an offset/limit relativiere ich in Schulungen bewusst wieder. Cursor-Paginierung ist kein Selbstzweck, und es gibt Fälle, in denen offset völlig ausreicht:
- Die Liste ist im Wesentlichen stabil, etwa ein historisches Archiv oder ein abgeschlossener Report, in den nichts mehr eingefügt wird.
- Der Nutzer soll gezielt zu Seite 7 springen können – nummerierte Seiten sind mit reinen Cursorn nicht ohne Weiteres darstellbar, weil man keine absolute Position kennt.
- Die Datenmengen sind klein und die Seiten flach, sodass weder Konsistenz- noch Performance-Probleme auftreten.
Es geht also nicht um Dogma, sondern um eine bewusste Wahl. Meine Faustregel: Sobald eine Liste sich unter dem Nutzer verändern kann und er sie potenziell tief durchblättert – Feeds, Timelines, Aktivitätsströme – ist cursor-basierte Paginierung mit dem Connections-Muster die richtige Antwort. Für ein stabiles, überschaubares Nachschlagewerk darf offset gern bleiben.
Fazit
Paginierung wirkt banal, bis man sie unter Bewegung betrachtet. Offset zählt Positionen, und Positionen verschieben sich – das Ergebnis sind Duplikate und Lücken, die im ruhigen Demo-Betrieb nie auftauchen und im Produktivbetrieb schwer zu fassen sind. Der cursor-basierte Ansatz ersetzt die Frage "ab welcher Position" durch "nach welchem Element" und wird damit gegen Veränderungen an der Liste robust.
Das Connections-Muster gießt diese Idee in eine saubere Struktur: Edges tragen den listenspezifischen Cursor, Nodes bleiben reine Datensätze, und pageInfo liefert mit hasNextPage und endCursor genau die zwei Angaben, die ein Client zum korrekten Weiterblättern braucht. Dazu kommen die Details, die den Unterschied zwischen Demo und Produktion ausmachen – ein Extra-Element für hasNextPage, ein Tie-Breaker in der Sortierung, eine Obergrenze für die Seitengröße und die Disziplin, den Cursor wirklich opak zu behandeln. Wer diese vier Dinge beherzigt, hat eine Paginierung, die auch dann noch stimmt, wenn die Liste sich unter den Fingern des Nutzers bewegt.
Weiterführende Quellen
- Grounding-Repository: introduction-graphql (
introduction-graphql/paging.md) - GraphQL Pagination – offizielle Lernressource
- GraphQL Cursor Connections Specification
Kommentare
Kommentar schreiben