# GraphQL-Paginierung: Cursor, Connections und das Relay-Muster

URL: https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/

Wer eine Liste über Positionen durchblättert, verlässt sich stillschweigend darauf, dass in der Zwischenzeit niemand schreibt. Im Produktivbetrieb trägt diese Annahme selten. Eine Position ist kein Anker in den Daten, sie ist eine Zählung – und Zählungen verschieben sich, sobald vorne ein Datensatz dazukommt oder verschwindet. Die Folgeanfrage stellt dann nicht mehr die Frage, die der Client stellen wollte.

Paginierung ist deshalb einer der Punkte, an denen sich zeigt, ob eine API robust ist oder nur im Demo-Fall funktioniert. Solange die Liste stillsteht, tut offset/limit tadellos seinen Dienst. Sobald sie sich unter dem blätternden Client verändert – und das ist der Normalfall, nicht die Ausnahme – entstehen Fehler, die schwer zu reproduzieren und noch schwerer zu erklären sind. Genau dafür 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.

```mermaid
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. Offset ist wie die 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.

Ein Punkt, der in Code-Reviews regelmäßig auffällt: 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?

- `edges` ist die Liste der Kanten. Jede Edge enthält `node` (den reinen Datensatz, ohne List-Wrapper) und `cursor` (den opaken Zeiger auf genau diese Edge).
- `pageInfo.hasNextPage` sagt, ob es nach der aktuellen Seite noch weitere Elemente gibt. Das ist die maßgebliche Abbruchbedingung fürs Blättern.
- `pageInfo.hasPreviousPage` ist das Gegenstück für die Rückwärtsrichtung.
- `pageInfo.endCursor` ist der Cursor des letzten Elements der aktuellen Seite – genau der Wert, den der Client als `after` in die Folgeanfrage steckt.
- `pageInfo.startCursor` ist das Pendant am Anfang der Seite, relevant fürs Rückwärtsblättern mit `before`.

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, hat aber einen Haken: `totalCount` ist **nicht** Teil der Connections-Spezifikation. Es ist eine verbreitete optionale Erweiterung, und je nach Datenquelle kann ein exaktes `COUNT` teuer sein. Wer es aufnimmt, sollte das bewusst tun und nicht in der Annahme, es gehöre zum Standard dazu.

## Ein Beispiel-Schema

Genug Prosa, schauen wir auf SDL. Eine Query, die Nutzer paginiert ausliefert, sieht im Connections-Muster so aus:

```graphql
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:

```graphql
query FirstPage {
  users(first: 2) {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
```

Und die Folgeanfrage nimmt den `endCursor` der vorigen Antwort als `after`:

```graphql
query NextPage {
  users(first: 2, after: "dXNlcjpjdXJzb3I6NDI=") {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
```

Viele Einführungsbeispiele – auch mein eigenes Grundlagenmaterial – zeigen vereinfachte Varianten, etwa `skip`/`take` oder ein `after: 99`, bei dem der Cursor verdächtig nach einer ID oder einem Offset aussieht. Zum Erklären taugt das, das echte Relay-Muster ist es nicht. Ein produktiver Cursor ist opak und sieht eben *nicht* nach einer lesbaren Zahl aus. Diese Unterscheidung lohnt den zusätzlichen Satz, sonst rechnet später jemand auf dem Client mit dem Cursor herum.

## 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`.

```js
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:

```mermaid
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:

```sql
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

Cursor-Paginierung ist kein Selbstzweck. 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. 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](https://github.com/mikebild/introduction-graphql) (`introduction-graphql/paging.md`)
- [GraphQL Pagination – offizielle Lernressource](https://graphql.org/learn/pagination/)
- [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm)
