{ }
type
GraphQL · Pagination · Relay · Magento · API-Design
Pagination in GraphQL:
Cursor vs. Offset sauber umsetzen

Pagination ist in GraphQL keine Kleinigkeit. Wer einfach Offset und Limit ins Schema schreibt, hat eine funktionierende Lösung – aber keine robuste. Cursor-basierte Pagination nach der Relay-Spezifikation ist aufwändiger, löst aber genau die Probleme, die Offset-Pagination bei wachsenden Datenmengen und parallelen Schreibzugriffen schafft.

12 Min. Lesezeit Relay-Cursor · pageInfo · Resolver · Magento · Vergleich GraphQL · REST-Vergleich · Produktionsbetrieb

1. Warum Pagination in GraphQL kein trivialer Parameter ist

Pagination gehört zu den Themen, die in GraphQL-APIs schnell unterschätzt werden. Ein einfaches limit und offset als Argument reicht für den ersten Prototyp – aber sobald Datenmengen wachsen, mehrere Benutzer gleichzeitig schreiben oder Frontend-Teams stabile Seitennavigation benötigen, zeigen sich die Grenzen dieses Ansatzes deutlich. Die GraphQL-Community hat darauf reagiert und mit der Relay-Spezifikation ein ausgereiftes Muster für Cursor-basierte Pagination definiert, das heute von den meisten großen GraphQL-Implementierungen unterstützt wird.

Der Unterschied liegt nicht nur in der technischen Umsetzung, sondern im konzeptionellen Ansatz: Offset-Pagination denkt in absoluten Positionen, Cursor-Pagination in stabilen Zeigern auf Datensätze. Das klingt abstrakt, macht sich aber sehr konkret bemerkbar, wenn zwischen zwei Anfragen neue Datensätze eingefügt werden und sich damit alle Positionen verschieben. Wer das falsche Modell für seinen Anwendungsfall wählt, baut sich ein Stabilitätsproblem in die API, das schwer rückgängig zu machen ist.

2. Offset-Pagination: Einfach, verständlich, limitiert

Offset-Pagination arbeitet mit zwei Parametern: limit (wie viele Einträge zurückgegeben werden) und offset (wie viele Einträge übersprungen werden). Das Modell ist intuitiv und direkt auf SQL-Ebene abbildbar: SELECT * FROM products LIMIT 20 OFFSET 40. Für einfache Listen mit stabilen Daten — zum Beispiel Einstellungsseiten in einem Admin-Panel — ist Offset-Pagination vollkommen ausreichend. Der Implementierungsaufwand ist gering, die mentale Komplexität überschaubar.

Die Probleme beginnen, wenn die Datenbasis nicht stabil ist. Wird zwischen zwei Requests ein Produkt eingefügt, verschiebt sich die gesamte Liste um eine Position, und der Client bekommt beim nächsten Request einen Datensatz doppelt oder überspringt einen. Bei großen Offsets entstehen außerdem Performance-Probleme auf Datenbankebene: Die Datenbank muss alle Datensätze bis zum Offset materialisieren, auch wenn sie nie zurückgegeben werden. Ab etwa 10.000 Datensätzen Offset macht sich das messbar. Offset-Pagination ist daher gut für stabile, überschaubare Datensätze, aber ungeeignet für Feeds, Produktlisten und andere häufig veränderte Collections.


# Offset-based pagination — simple but limited for live data
query ProductsWithOffset {
  products(
    search: "jacket"
    pageSize: 20
    currentPage: 3
  ) {
    total_count
    page_info {
      current_page
      page_size
      total_pages
    }
    items {
      sku
      name
      price_range {
        minimum_price {
          final_price { value currency }
        }
      }
    }
  }
}

3. Cursor-Pagination und die Relay-Spezifikation

Die Relay-Spezifikation beschreibt eine standardisierte Art, Cursor-Pagination in GraphQL umzusetzen. Statt limit und offset verwendet man die Argumente first, after, last und before. Der Cursor ist ein opaker String — typischerweise eine Base64-kodierte Darstellung eines eindeutigen Wertes wie einer ID oder eines Timestamps —, den der Server an jeden Eintrag anhängt. Der Client sendet diesen Cursor zurück, um die nächste Seite anzufragen. Das Ergebnis enthält immer ein pageInfo-Objekt mit hasNextPage, hasPreviousPage, startCursor und endCursor.

Der zentrale Vorteil: Der Server muss keine absolute Position mehr kennen, sondern nur den letzten gesehenen Datensatz. Das macht die Abfrage stabil gegenüber Einfügeoperationen und erlaubt auf Datenbankebene eine effiziente Implementierung mit WHERE id > cursor_id ORDER BY id LIMIT n. Cursor sind außerdem flexibler: Sie können zusammengesetzte Sortierschlüssel kodieren und damit auch komplexe Sortierungen stabil paginieren — etwas, das mit reinen Offsets nicht möglich ist.


# Relay-style cursor pagination — stable and efficient
query ProductsWithCursor($after: String) {
  productConnection(first: 20, after: $after) {
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    edges {
      cursor
      node {
        id
        sku
        name
        price_range {
          minimum_price {
            final_price { value currency }
          }
        }
      }
    }
    totalCount
  }
}

4. Schema-Design: Connection-Typen korrekt aufbauen

Das Relay-Muster führt eine eigene Typstruktur ein, die auf den ersten Blick umständlich wirkt, aber Wiederverwendbarkeit und Konsistenz über alle paginierten Listen garantiert. Der zentrale Typ ist die Connection: sie enthält edges (eine Liste von Edge-Objekten) und pageInfo. Jede Edge enthält cursor und node (den eigentlichen Datensatz). Diese Verschachtelung erlaubt es, den Cursor an jedem einzelnen Element zu transportieren und nicht nur an der Gesamtliste. Für unterschiedliche Entitäten definiert man unterschiedliche Connection- und Edge-Typen, die alle dasselbe PageInfo-Interface teilen.

In der Schema-Definition ist wichtig, dass das PageInfo`-Objekt als eigener Typ definiert wird und nicht inline eingebettet ist. Das macht ihn wiederverwendbar über alle Connections hinweg. Die Argumente first: Int, after: String, last: Int, before: String sind Teil des Relay-Standards und sollten konsistent benannt werden. Eigene Argumente wie filters oder sort können parallel zu den Pagination-Argumenten stehen, ohne die Struktur zu brechen.


# Schema definition following Relay Connection specification
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type ProductEdge {
  cursor: String!
  node: ProductNode!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ProductNode {
  id: ID!
  sku: String!
  name: String!
  price: Float
}

type Query {
  # Relay-compatible connection field alongside optional filters
  productConnection(
    first: Int
    after: String
    last: Int
    before: String
    search: String
    sort: ProductSortInput
  ): ProductConnection!
}

5. Resolver-Implementierung: Cursor erzeugen und auswerten

Die Resolver-Implementierung ist der aufwändigste Teil der Cursor-Pagination. Der Cursor muss deterministisch aus dem Datensatz abgeleitet werden — üblicherweise aus der primären ID oder einer Kombination aus Sortierfeld und ID. Base64 dient dabei nicht als Sicherheitsmaßnahme, sondern als Hinweis an Client-Entwickler, dass der Cursor als opaker Wert behandelt werden soll und nicht manuell konstruiert werden darf. Der Resolver dekodiert den eingehenden after-Cursor, transformiert ihn in eine Datenbankbedingung und gibt zusammen mit den Ergebnissen das pageInfo-Objekt zurück.

Ein häufiger Fehler: hasNextPage wird falsch berechnet. Die korrekte Methode ist, eine Seite mehr als angefordert zu laden (first + 1 Datensätze abzufragen), und wenn mehr Datensätze zurückkamen als angefordert, hasNextPage: true zu setzen und das überflüssige letzte Element wegzulassen. Das ist effizienter als ein separates COUNT-Query für jede Seitenanfrage. Beim rückwärts Paginieren mit last und before gilt die gleiche Logik spiegelverkehrt, und die Reihenfolge der Ergebnisse muss entsprechend umgekehrt werden.

6. Typische Fehler und Fallstricke

Der am häufigsten auftretende Fehler bei Cursor-Pagination ist die Verwendung von sequentiellen Integer-IDs als Cursor ohne Enkodierung. Wenn Client-Entwickler erkennen, dass cursor: "42" einfach eine ID ist, beginnen sie Cursor manuell zu konstruieren — mit der Folge, dass die API-Abstraktion untergraben wird und bei Schema-Änderungen Frontend-Code bricht. Base64-Kodierung mit einem Präfix wie base64("product:42") macht klar, dass der Cursor opak behandelt werden soll.

Ein weiteres häufiges Problem ist fehlendes oder falsches pageInfo. Wer hasNextPage immer auf false setzt oder vergisst, macht Cursor-Pagination de facto nutzlos. Das Frontend kann dann nicht erkennen, ob weitere Daten existieren. Auch startCursor und endCursor müssen korrekt sein — sie sind nicht dasselbe wie der Cursor des ersten und letzten Eintrags in der edges-Liste, auch wenn das häufig so implementiert wird. Korrekt ist es nur, wenn die Sortierung bei jedem Request identisch ist.

7. Pagination in Magento GraphQL

Magento 2 implementiert Offset-Pagination über die Parameter pageSize und currentPage. Das ist konsistent mit der internen SearchCriteria-Schicht und für die meisten Produktlisten-Use-Cases ausreichend. Der page_info-Typ liefert current_page, page_size und total_pages. Wer eigene Queries und Resolver für Magento schreibt und eine andere Paginierungsstrategie benötigt, muss die Connection-Typen selbst im Schema definieren und den Resolver entsprechend aufbauen.

Besondere Vorsicht ist bei großen Produktkatalogen geboten: Magentos Suche über OpenSearch liefert zwar korrekte Ergebnisse, aber sehr große pageSize-Werte (z. B. 500) können den Suchserver belasten und die Antwortzeit signifikant erhöhen. Auch das total_count-Feld kann bei komplexen Filterabfragen teuer sein, da es eine separate Zählanfrage auslöst. In Performance-kritischen Szenarien ist es sinnvoll, total_count separat zu kachen oder für Infinite-Scroll-Use-Cases ganz wegzulassen und stattdessen nur hasNextPage zu liefern.

8. Cursor vs. Offset im direkten Vergleich

Beide Modelle haben ihre Berechtigung — die Entscheidung hängt vom konkreten Anwendungsfall ab. Offset ist einfacher zu implementieren und zu debuggen, Cursor-Pagination ist stabiler und skalierbarer. Die folgende Tabelle fasst die wesentlichen Unterschiede zusammen.

Kriterium Offset-Pagination Cursor-Pagination (Relay) Empfehlung
Stabilität bei Einfügungen Daten können doppelt erscheinen Stabil — Cursor zeigt auf Datensatz Cursor für Feeds und häufig veränderte Listen
Performance bei großem Offset Langsamer (DB muss überspringen) Konstant schnell Cursor ab ca. 10.000+ Datensätzen
Implementierungsaufwand Gering Höher (Edge, PageInfo, Cursor-Logik) Offset für Admin-UIs und stabile Listen
Sprung zu Seite X Direkt möglich Nicht möglich (sequentiell) Offset wenn Seitennavigation gefordert
Infinite Scroll / Load More Fehleranfällig bei parallelen Writes Ideal geeignet Cursor für alle Feed-artigen UIs

9. Zusammenfassung

Pagination in GraphQL ist eine Architekturentscheidung, keine Implementierungsdetail. Offset-Pagination ist schnell zu implementieren und verständlich, zeigt aber bei wachsenden Datenmengen und häufigen Schreiboperationen klare Grenzen. Cursor-Pagination nach der Relay-Spezifikation ist aufwändiger, liefert aber stabile, skalierbare Ergebnisse für alle Use-Cases, in denen die Daten nicht statisch sind. Die Wahl sollte bewusst und abhängig vom konkreten Anwendungsfall getroffen werden — und einmal getroffen, ist sie schwer rückgängig zu machen, weil Clients sich auf die Cursor-Struktur verlassen.

In Magento GraphQL ist Offset-Pagination der Standard und für die meisten Katalog-Use-Cases ausreichend. Wer eigene Queries mit anderen Anforderungen schreibt, sollte die Relay-Typen von Anfang an einplanen und die Cursor-Logik sauber im Resolver kapseln — nicht im Controller oder in der Business-Logik. Das hält den Resolver testbar und den Vertrag mit dem Frontend explizit.

Pagination in GraphQL — Das Wichtigste auf einen Blick

Offset-Pagination

Einfach, direkt in SQL abbildbar, für Admin-UIs und stabile Listen ideal — aber instabil bei parallelen Schreibzugriffen und langsam bei großem Offset.

Cursor-Pagination (Relay)

Stabil, performant bei großen Datensätzen und ideal für Feeds und Infinite Scroll. Höherer Implementierungsaufwand, aber klar bessere Langzeitqualität.

Magento-Standard

pageSize + currentPage via SearchCriteria — ausreichend für Katalogseiten, aber Performance-kritisch bei sehr großen pageSize-Werten und teuren total_count-Abfragen.

Praxisregel

Cursor für alle Feed-artigen, häufig veränderten Listen. Offset für stabile Daten und Seitennavigation. Die Entscheidung früh treffen — sie ist schwer rückgängig zu machen.

11. FAQ: Pagination in GraphQL

1Cursor vs. Offset: Was ist der Hauptunterschied?
Offset arbeitet mit absoluten Positionen und ist instabil bei parallelen Writes. Cursor zeigt auf konkrete Datensätze und ist stabil — unabhängig davon, was vor oder nach dem Cursor eingefügt wird.
2Was definiert die Relay-Spezifikation?
Connection-Typen mit edges/node/cursor, pageInfo mit hasNextPage/hasPreviousPage/startCursor/endCursor und die Argumente first/after/last/before. Eine API, die diese Struktur implementiert, ist Relay-kompatibel.
3Warum Base64 für Cursor?
Nicht als Sicherheitsmaßnahme, sondern als Signal: Der Cursor ist ein opaker Wert und soll nicht manuell konstruiert werden. Wenn Clients erkennen, dass cursor: "42" eine einfache ID ist, fangen sie an, Cursor selbst zu bauen — das ist ein Anti-Pattern.
4Wie berechne ich hasNextPage korrekt?
Nicht mit einem separaten COUNT-Query. Stattdessen first+1 Datensätze abfragen: wenn mehr als first zurückkommen, gibt es eine nächste Seite. Das letzte Element wegschneiden und hasNextPage: true setzen.
5Wie paginiert Magento GraphQL?
pageSize + currentPage als Offset-Pagination über die SearchCriteria-Schicht. page_info liefert current_page, page_size und total_pages. Für eigene Cursor-Pagination müssen eigene Typen und Resolver geschrieben werden.
6Wann ist Offset-Pagination sinnvoll?
Für stabile Daten, Admin-Tabellen, Seitennavigation mit Seitensprüngen und Szenarien, in denen Clients direkt zu Seite N springen müssen. Cursor-Pagination ermöglicht das nicht.
7Warum ist Offset bei 50.000 Datensätzen langsam?
Die Datenbank muss alle 50.000 Datensätze materialisieren und verwerfen, bevor die eigentlichen Ergebnisse zurückgegeben werden. Cursor-Pagination mit WHERE id > X springt direkt zum richtigen Startpunkt.
8Was enthält pageInfo zwingend?
hasNextPage (Boolean!), hasPreviousPage (Boolean!), startCursor (String) und endCursor (String). Alles andere ist optional. Fehlendes oder falsches pageInfo macht Cursor-Pagination für Clients unbrauchbar.
9Kann ich Cursor-Pagination rückwärts (last/before) implementieren?
Ja, aber es ist aufwändiger. Die Datenbankabfrage muss umgekehrt werden (ORDER BY id DESC, WHERE id < cursor), und die Ergebnisliste muss vor der Rückgabe erneut umgekehrt werden, damit die Reihenfolge für den Client stimmt.
10Brauche ich totalCount bei Cursor-Pagination?
Nicht immer. Für Infinite-Scroll-UIs reicht hasNextPage. totalCount ist ein separates COUNT-Query und kann teuer sein — wenn nicht benötigt, sollte es nicht abgefragt werden oder aus dem Cache kommen.