Cursor‑Paginierung sorgt dafür, dass Listen stabil bleiben, wenn sich Daten ändern. Erfahre, warum Offset‑Paging bei Einfügungen und Löschungen versagt und wie man saubere Cursors implementiert.

Du öffnest einen Feed, scrollst ein bisschen — und plötzlich läuft etwas schief. Du siehst denselben Eintrag zweimal. Etwas, das gerade noch da war, fehlt. Eine Zeile, die du antippen wolltest, rutscht weg und du landest auf der falschen Detailseite.
Das sind für Nutzer sichtbare Fehler, auch wenn deine API-Antworten einzeln betrachtet „korrekt“ aussehen. Die üblichen Symptome sind leicht zu erkennen:
Auf Mobilgeräten wird das noch schlimmer. Leute pausieren, wechseln die App, verlieren die Verbindung und machen später weiter. In der Zwischenzeit kommen neue Einträge dazu, alte werden gelöscht und manche bearbeitet. Wenn deine App weiterhin „Seite 3“ per Offset anfragt, können Seitengrenzen während des Scrollens verschoben werden. Das Ergebnis ist ein Feed, der sich instabil und unzuverlässig anfühlt.
Das Ziel ist einfach: Sobald ein Nutzer vorwärts scrollt, sollte die Liste wie eine Momentaufnahme funktionieren. Neue Elemente können existieren, dürfen aber nicht das reshufflen verursachen, was der Nutzer bereits durchblättert. Der Nutzer sollte eine gleichmäßige, vorhersagbare Folge erhalten.
Keine Paginierungsmethode ist perfekt. In echten Systemen gibt es gleichzeitige Schreibvorgänge, Bearbeitungen und mehrere Sortieroptionen. Aber Cursor-Paginierung ist meist sicherer als Offset-Paginierung, weil sie von einer festen Position in einer stabilen Reihenfolge aus paginiert, statt von einer sich bewegenden Zeilenzahl.
Offset-Paginierung ist das „überspringe N, nimm M“-Verfahren. Du sagst der API, wie viele Einträge sie überspringen soll (offset) und wie viele sie zurückgeben soll (limit). Mit limit=20 bekommst du 20 Elemente pro Seite.
Konzepte:
GET /items?limit=20&offset=0 (erste Seite)GET /items?limit=20&offset=20 (zweite Seite)GET /items?limit=20&offset=40 (dritte Seite)Die Antwort enthält normalerweise die Elemente plus genug Infos, um die nächste Seite anzufordern.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Es ist beliebt, weil es sich gut auf Tabellen, Admin‑Listen, Suchergebnisse und einfache Feeds abbilden lässt. Mit SQL ist es leicht umzusetzen via LIMIT und OFFSET.
Der Haken ist die versteckte Annahme: das Dataset bleibt still, während der Nutzer durchblättert. In echten Apps verschieben sich Reihen — neue werden eingefügt, alte gelöscht, Sortierschlüssel ändern sich. Hier beginnen die „mysteriösen Fehler“.
Offset-Paginierung geht davon aus, dass die Liste zwischen den Anfragen gleich bleibt. Aber echte Listen bewegen sich. Wenn sich die Liste verschiebt, zeigt ein Offset wie „überspringe 20“ nicht mehr auf die gleichen Elemente.
Stell dir einen Feed vor, sortiert nach created_at desc (neuste zuerst), Seitengröße 3.
Du lädst Seite 1 mit offset=0, limit=3 und bekommst [A, B, C].
Jetzt wird ein neues Element X erstellt und erscheint ganz oben. Die Liste ist nun [X, A, B, C, D, E, F, ...]. Du lädst Seite 2 mit offset=3, limit=3. Der Server überspringt [X, A, B] und gibt [C, D, E] zurück.
Du hast C gerade wieder gesehen (ein Duplikat), und später wirst du ein Element verpassen, weil sich alles nach unten verschoben hat.
Löschungen verursachen das Gegenproblem. Beginne mit [A, B, C, D, E, F, ...]. Du lädst Seite 1 und siehst [A, B, C]. Bevor du Seite 2 lädst, wird B gelöscht, also ist die Liste [A, C, D, E, F, ...]. Seite 2 mit offset=3 überspringt [A, C, D] und gibt [E, F, G] zurück. D wird zu einer Lücke, die du nie abrufst.
In Feeds, die neueste zuerst zeigen, passieren Einfügungen oben — genau das verschiebt jedes spätere Offset.
Eine „stabile Liste“ ist das, was Nutzer erwarten: beim Vorwärtsscrollen springen Elemente nicht, wiederholen sich nicht oder verschwinden nicht ohne ersichtlichen Grund. Es geht weniger darum, die Zeit anzuhalten, als die Paginierung vorhersagbar zu machen.
Zwei Ideen werden oft vermischt:
created_at mit einem Tie‑Breaker wie id), sodass zwei Anfragen mit denselben Eingaben die gleiche Reihenfolge zurückgeben.Refresh und Scroll‑Forward sind unterschiedliche Aktionen. Refresh bedeutet „zeige mir, was jetzt neu ist“ — oben kann sich also ändern. Scroll‑Forward bedeutet „mach weiter von dort, wo ich war“ — du solltest keine Duplikate oder unerwartete Lücken sehen, die durch verschobene Seitengrenzen entstehen.
Eine einfache Regel, die die meisten Paginierungsfehler verhindert: "Beim Vorwärtsscrollen dürfen niemals Wiederholungen angezeigt werden."
Cursor‑Paginierung bewegt sich durch eine Liste mit einem Lesezeichen statt mit einer Seitennummer. Statt „gib mir Seite 3“ sagt der Client „mach hier weiter“.
Der Vertrag ist klar:
Das verträgt Einfügungen und Löschungen besser, weil der Cursor an einer Position in der sortierten Reihenfolge anhaftet, nicht an einer Zeilenzahl.
Die nicht verhandelbare Voraussetzung ist eine deterministische Sortierreihenfolge. Du brauchst eine stabile Ordnung und einen konsistenten Tie‑Breaker, sonst ist der Cursor kein zuverlässiges Lesezeichen.
Wähle zuerst die Sortierreihenfolge, die zur Art passt, wie Leute die Liste lesen. Feeds, Nachrichten und Aktivitätslogs sind meist „neueste zuerst“. Historien wie Rechnungen oder Audit‑Logs sind oft „älteste zuerst".
Ein Cursor muss eine Position in dieser Reihenfolge eindeutig identifizieren. Wenn zwei Elemente denselben Cursorwert teilen können, bekommst du irgendwann Duplikate oder Lücken.
Gängige Optionen und worauf du achten solltest:
created_at allein: einfach, aber unsicher, wenn viele Reihen denselben Timestamp haben.id allein: sicher, wenn IDs monoton sind, passt aber vielleicht nicht zur gewünschten Produktreihenfolge.created_at + id: meist die beste Kombination (Timestamp für die Produktreihenfolge, id als Tie‑Breaker).updated_at als primäre Sortierung: riskant für unendliches Scrollen, weil Bearbeitungen Elemente zwischen Seiten verschieben können.Wenn du mehrere Sortieroptionen anbietest, behandle jeden Sortiermodus als eigene Liste mit eigenen Cursor‑Regeln. Ein Cursor macht nur für genau eine Ordnung Sinn.
Du kannst die API‑Oberfläche klein halten: zwei Eingaben, zwei Ausgaben.
Sende ein limit (wie viele Items du möchtest) und optional einen cursor (wo du weitermachen willst). Fehlt der Cursor, liefert der Server die erste Seite.
Beispielanfrage:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Gib die Items und einen next_cursor zurück. Wenn es keine nächste Seite gibt, next_cursor: null. Clients sollten den Cursor als Token behandeln, nicht als etwas zum Bearbeiten.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Serverseitige Logik in einfachen Worten: in einer stabilen Reihenfolge sortieren, mit dem Cursor filtern, dann das Limit anwenden.
Wenn du nach neuestem zuerst (created_at DESC, id DESC) sortierst, dekodierst du den Cursor in (created_at, id), dann holst du Zeilen, bei denen (created_at, id) strikt kleiner ist als das Cursor‑Paar, wendest dieselbe Reihenfolge an und nimmst limit Zeilen.
Du kannst den Cursor als base64‑JSON‑Blob kodieren (einfach) oder als signiertes/verschlüsseltes Token (mehr Arbeit). Opaque Tokens sind sicherer, weil du intern später ändern kannst, ohne Clients zu brechen.
Setze außerdem vernünftige Defaults: ein mobiles Default (oft 20–30), ein Web‑Default (oft 50) und ein hartes Server‑Max, damit ein fehlerhafter Client nicht 10.000 Zeilen anfordert.
Eine stabile Ansicht verspricht vor allem eines: sobald der Nutzer vorwärts scrollt, sollten die Elemente, die er noch nicht gesehen hat, nicht herumhüpfen, weil jemand anderes datensätze erstellt, löscht oder bearbeitet.
Mit Cursor‑Paginierung sind Einfügungen am einfachsten. Neue Datensätze sollten bei einem Refresh auftauchen, nicht in der Mitte bereits geladener Seiten. Wenn du nach created_at DESC, id DESC sortierst, leben neue Elemente natürlich vor der ersten Seite, sodass dein existierender Cursor in ältere Elemente weiterläuft.
Löschungen sollten die Liste nicht neu anordnen. Wenn ein Element gelöscht wird, wird es einfach nicht mehr zurückgegeben, wenn du dort ankommen würdest. Wenn du gleichbleibende Seitengrößen brauchst, hole weiter, bis du limit sichtbare Elemente gesammelt hast.
Bearbeitungen sind der Punkt, an dem Teams leicht wieder Fehler einführen. Die Kernfrage ist: kann eine Bearbeitung die Sortierposition ändern?
Snapshot‑Verhalten ist meist besser für Scroll‑Listen: paginiere nach einem unveränderlichen Schlüssel wie created_at. Bearbeitungen können den Inhalt ändern, aber das Element springt nicht an eine neue Position.
Live‑Feed‑Verhalten sortiert etwa nach edited_at. Das kann Sprünge verursachen (ein altes Item wird bearbeitet und rückt nach oben). Wenn du das wählst, behandle die Liste als ständig veränderlich und gestalte die UX rund um Refresh.
Mach den Cursor nicht davon abhängig, „diese exakte Zeile zu finden“. Kodier stattdessen die Position, z. B. {created_at, id} des zuletzt zurückgegebenen Elements. Dann basiert die nächste Abfrage auf Werten, nicht auf Zeilenexistenz:
WHERE (created_at, id) < (:created_at, :id)id) einschließen, um Duplikate zu vermeidenVorwärts‑Paginierung ist der einfache Teil. Schwieriger sind Zurück‑Paging, Refresh und zufälliger Zugriff.
Für Zurück‑Paging funktionieren zwei Ansätze oft:
next_cursor für ältere Elemente und prev_cursor für neuere) und behalte eine on‑screen Sortierung bei.Zufällige Sprünge sind mit Cursorn schwieriger, weil „Seite 20“ keine stabile Bedeutung hat, wenn sich die Liste ändert. Wenn du wirklich springen musst, springe zu einem Anker wie „um diesen Zeitstempel herum“ oder „beginnend ab dieser message id“, nicht zu einem Seitenindex.
Auf Mobilgeräten ist Caching wichtig. Speichere Cursors pro Listenstatus (Query + Filter + Sort) und behandle jeden Tab/View als eigene Liste. Das verhindert „Tab wechseln und alles gerät durcheinander“‑Verhalten.
Die meisten Cursor‑Paginierungsprobleme sind nicht die Datenbank. Sie entstehen durch kleine Inkonsistenzen zwischen Anfragen, die nur im realen Verkehr sichtbar werden.
Die größten Übeltäter:
created_at), sodass Ties Duplikate oder fehlende Elemente erzeugen.next_cursor zurückgeben, der nicht mit dem tatsächlich zuletzt zurückgegebenen Element übereinstimmt.Wenn du Apps auf Plattformen wie Koder.ai baust, treten diese Edge‑Cases schnell auf, weil Web‑ und Mobile‑Clients denselben Endpoint teilen. Ein klarer Cursor‑Vertrag und eine deterministische Sortierregel halten beide Clients konsistent.
Bevor du Paginierung als „fertig“ deklarierst, prüfe das Verhalten bei Einfügungen, Löschungen und Retry‑Szenarien.
next_cursor stammt vom zuletzt zurückgegebenen Eintraglimit hat ein sicheres Maximum und ein dokumentiertes DefaultFür Refresh wähle eine klare Regel: entweder Nutzer ziehen zum Aktualisieren, um neuere Elemente oben zu sehen, oder du fragst periodisch „gibt es etwas neueres als mein erstes Element?“ und zeigst einen „Neue Elemente“-Button. Konsistenz sorgt dafür, dass die Liste stabil statt gespenstisch wirkt.
Stell dir ein Support‑Postfach vor, das Agenten im Web nutzen, während ein Manager dasselbe Postfach mobil prüft. Die Liste ist nach neusten zuerst sortiert. Erwartung: beim Vorwärts‑Scrollen springen Elemente nicht, wiederholen sich nicht oder verschwinden.
Mit Offset‑Paging lädt ein Agent Seite 1 (Items 1–20), dann Seite 2 (offset=20). Während er liest, kommen zwei neue Nachrichten oben an. Jetzt zeigt offset=20 auf eine andere Stelle als vorher. Der Nutzer sieht Duplikate oder verpasst Nachrichten.
Mit Cursor‑Paginierung fragt die App „die nächsten 20 Items nach diesem Cursor“ an, wobei der Cursor auf dem zuletzt tatsächlich gesehenen Item basiert (typischerweise (created_at, id)). Neue Nachrichten können jederzeit ankommen, aber die nächste Seite beginnt trotzdem direkt nach dem letzten Element, das der Nutzer gesehen hat.
Ein einfacher Test vor dem Rollout:
Wenn du schnell prototypst, kann Koder.ai dir helfen, den Endpoint und die Client‑Flows aus einem Chat‑Prompt zu skizzieren und dann sicher mit Planning Mode, Snapshots und Rollbacks zu iterieren, falls eine Paginierungsänderung beim Testen überrascht.
Offset-Paginierung sagt im Grunde „überspringe N Zeilen“. Wenn zwischen zwei Abfragen neue Zeilen eingefügt oder alte gelöscht werden, verschiebt sich die Zeilenzahl. Dasselbe Offset kann plötzlich auf andere Elemente zeigen als vorhin — das erzeugt für Nutzer Duplikate und Lücken beim Scrollen.
Cursor-Paginierung verwendet ein Lesezeichen, das die Position „nach dem letzten gesehenen Element“ repräsentiert. Die nächste Anfrage setzt genau an dieser Position in einer deterministischen Reihenfolge fort, sodass Einfügungen oben und Löschungen in der Mitte die Seitenbegrenzung nicht so verschieben wie Offsets.
Verwende eine deterministische Sortierung mit einem Tie-Breaker, meist (created_at, id) in derselben Richtung. created_at gibt die produktfreundliche Reihenfolge, id macht jede Position eindeutig, sodass du bei gleichen Zeitstempeln nichts wiederholst oder überspringst.
Nach updated_at zu sortieren kann dazu führen, dass Elemente beim Bearbeiten zwischen Seiten springen — das bricht die Erwartung einer stabilen Vorwärts-Scroll-Erfahrung. Wenn du eine „zuletzt bearbeitet“-Ansicht brauchst, gestalte die UI für regelmäßige Aktualisierungen und akzeptiere Reordering statt einer stabilen unendlichen Scroll.
Gib ein opaques Token als next_cursor zurück und lass den Client es unverändert zurücksenden. Eine einfache Methode ist, das letzte Element (created_at, id) als base64-kodiertes JSON zu übergeben. Wichtig ist, dass der Cursor als undurchsichtiges Token behandelt wird, damit du intern später ändern kannst, ohne Clients zu brechen.
Baue die nächste Abfrage aus den Cursorwerten auf, nicht daraus „finde diese exakte Zeile“. Wenn das letzte Element gelöscht wurde, definiert das gespeicherte (created_at, id) trotzdem eine Position, sodass du sicher mit einer strikt-< (oder >) Bedingung im selben Sort fortfahren kannst.
Verwende einen strikten Vergleich und einen eindeutigen Tie-Breaker und nimm den Cursor immer vom tatsächlich zuletzt zurückgegebenen Element. Die meisten Wiederholungsfehler entstehen, weil <= statt < verwendet wird, der Tie-Breaker fehlt oder der next_cursor vom falschen Element generiert wurde.
Triff eine klare Entscheidung: Refresh lädt neuere Elemente oben, während Scroll-Forward von deinem existierenden Cursor aus in ältere Elemente fortsetzt. Vermische nicht die Refresh-Semantik mit dem Cursor-Fluss, sonst sehen Nutzer Reordering und empfinden die Liste als unzuverlässig.
Ein Cursor gilt nur für eine genaue Sortierung und eine feste Filtermenge. Wenn der Client Sortiermodus, Suche oder Filter ändert, muss er eine neue Paginierungssitzung ohne Cursor beginnen und Cursors pro Listenstatus getrennt speichern.
Cursor-Paginierung ist ideal für sequentielles Durchblättern, aber nicht für stabile „Seite 20“-Sprünge, weil sich das Dataset ändert. Wenn du springen musst, springe zu einem Anker wie „um diesen Zeitstempel herum“ oder „beginnend nach dieser id“ und paginiere von dort mit Cursorn.