Zurück zum Blog
Juli 2026·3 Min. LesezeitKI-generiert
Sprache:DeutschEnglishFrançaisÖsterreichischKlingonisch Originalfassung: English

Ein praktischer Leitfaden zur API-Pagination

#api#backend#databases

Gibt man jede Zeile einer wachsenden Tabelle in einer einzigen Antwort zurück, hat man ein Feature gebaut, das in der Entwicklung problemlos läuft und in Produktion zusammenbricht, sobald ein Kunde 50.000 Datensätze hat. Pagination ist die Lösung, aber der naive Ansatz (LIMIT/OFFSET) hat einen Korrektheitsfehler, der erst bei gleichzeitigen Schreibzugriffen auftritt – also genau unter den Bedingungen, unter denen deine API in Produktion läuft und die deine Tests vermutlich nicht abbilden.

Offset-Pagination: einfach und still falsch

Der naheliegende Ansatz: eine Seitenzahl übergeben, einen Offset berechnen und die Datenbank Zeilen überspringen lassen.

SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;
app.get('/posts', async (req, res) => {
  const page = Number(req.query.page ?? 1);
  const pageSize = 20;
  const offset = (page - 1) * pageSize;

  const posts = await db.query(
    'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [pageSize, offset],
  );
  res.json(posts);
});

Das funktioniert und ist die richtige Wahl für kleine, überwiegend statische Tabellen mit einer Oberfläche, die echte Seitenzahlen braucht (1, 2, 3 …). Sobald die Tabelle groß ist und aktiv beschrieben wird, hat es zwei echte Probleme.

Problem 1: Die Performance sinkt mit dem Offset

OFFSET 100000 springt nicht direkt zu Zeile 100.000 – die Datenbank muss die ersten 100.000 passenden Zeilen trotzdem scannen und verwerfen, bevor sie die nächste Seite zurückgeben kann. Seite 1 ist schnell; Seite 5.000 kann bei exakt derselben Abfrage deutlich langsamer sein.

Problem 2: Einträge verschieben sich bei gleichzeitigen Schreibzugriffen

Das ist der Korrektheitsfehler, nicht nur ein Performanceproblem. Angenommen, ein Client blättert durch Ergebnisse, sortiert nach created_at DESC, und zwischen den Anfragen für Seite 1 und Seite 2 wird eine neue Zeile eingefügt. Jede Zeile rutscht um eine Position nach unten – der letzte Eintrag von Seite 1 landet auf Seite 2, und der Client sieht ihn doppelt. Wird stattdessen eine Zeile gelöscht, rutscht alles nach oben, und der Client überspringt stillschweigend einen Eintrag. Beides erscheint nicht als Fehler; der Client bekommt einfach subtil falsche Daten.

Cursor-Pagination: löst beides

Statt „überspringe N Zeilen" sagt ein Cursor „gib mir die Zeilen nach dieser bestimmten". Der Cursor ist typischerweise der Sortierschlüssel der letzten Zeile der vorherigen Seite, keine Zeilenanzahl.

SELECT * FROM posts
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT 20;
app.get('/posts', async (req, res) => {
  const pageSize = 20;
  const cursor = req.query.cursor; // ISO timestamp of the last item seen, or undefined

  const posts = await db.query(
    cursor
      ? 'SELECT * FROM posts WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2'
      : 'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1',
    cursor ? [cursor, pageSize] : [pageSize],
  );

  const nextCursor = posts.length === pageSize ? posts[posts.length - 1].created_at : null;

  res.json({ items: posts, nextCursor });
});

Weil die Abfrage auf created_at < $1 filtert, statt eine Zeilenanzahl zu überspringen, verschiebt eine oben eingefügte neue Zeile nichts darunter – jeder folgende Cursor zeigt weiterhin auf dieselbe tatsächliche Zeile. Und mit einem Index auf created_at ist diese Abfrage ein schneller Bereichsscan, egal wie tief man in den Ergebnissen steckt – ohne die wachsenden Scankosten von OFFSET.

Der Cursor braucht einen eindeutigen Tiebreaker

created_at allein reicht nicht, wenn zwei Zeilen exakt denselben Zeitstempel haben – man könnte eine davon überspringen oder doppelt liefern. Nimm den Primärschlüssel als Tiebreaker dazu und kodiere beides in den Cursor:

SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
function encodeCursor(row) {
  return Buffer.from(`${row.created_at.toISOString()}|${row.id}`).toString('base64');
}

function decodeCursor(cursor) {
  const [createdAt, id] = Buffer.from(cursor, 'base64').toString().split('|');
  return { createdAt, id };
}

Die Base64-Kodierung des Cursors dient nicht der Sicherheit – sie macht den Wert aus Sicht des Clients zu einem opaken Token. Dadurch kann man später frei ändern, was darin steckt (ein Feld hinzufügen, Sortierspalten wechseln), ohne Clients zu brechen, die ihn einfach unverändert zurückgeben.

Was Clients tatsächlich sehen

Eine Cursor-paginierte Antwort sieht typischerweise so aus – keine Gesamtanzahl, keine Seitenzahl, nur „hier ist das nächste Token":

{
  "items": [ { "id": 512, "title": "..." }, { "id": 511, "title": "..." } ],
  "nextCursor": "MjAyNi0wNy0xN1QwOTozMDowMFp8NTEx"
}

Der Client speichert nextCursor und schickt ihn für die nächste Seite als ?cursor=... zurück. Für den Client ist er opak – er sollte ihn weder parsen noch von Hand bauen, und genau dieser Vertrag erlaubt es deinem Backend, die Pagination später intern zu ändern, ohne die API abwärtsinkompatibel zu machen.

Wann Offset-Pagination trotzdem sinnvoll ist

Cursor-Pagination gibt die Möglichkeit auf, zu einer beliebigen Seitenzahl zu springen („zeig mir Seite 47"), weil sich für eine noch nicht abgerufene Seite kein Cursor berechnen lässt. Braucht deine Oberfläche tatsächlich nummerierte Seiten mit direkten Sprüngen – etwa eine Admin-Tabelle mit Seitenauswahl –, ist Offset-Pagination weiterhin das richtige Werkzeug, besonders bei Tabellen, die so klein sind, dass die Performance-Klippe praktisch nie eine Rolle spielt. Greife zur Cursor-Pagination, wenn die Tabelle groß ist, gleichzeitig beschrieben wird und der Client nur „weiter" und „zurück" braucht – was auf die meisten Infinite-Scroll-Feeds und öffentlichen APIs zutrifft.

Fazit

Offset-Pagination ist einfach und für kleine Oberflächen mit Seitenzahlen völlig in Ordnung. Sobald eine Tabelle groß ist und gleichzeitig beschrieben wird, wird OFFSET langsamer und still inkorrekt, weil sich Seiten unter bewegten Daten verschieben. Cursor-Pagination – Filtern auf „nach dieser Zeile" statt „überspringe N Zeilen", mit eindeutigem Tiebreaker – löst beides, um den Preis, dass man nicht mehr beliebig zu Seiten springen kann.

NeuerHTTP-Caching-Header verstehenÄlterDatenbank-Connection-Pooling verstehen