Paginierungsmuster für moderne APIs
Vergleichen Sie Offset-, Cursor-, Keyset-, Page-Token- und Relay-GraphQL-Pagination mit TypeScript- und SQL-Beispielen für moderne APIs.
Für neue List-Endpunkte empfiehlt sich standardmäßig die Cursor-Paginierung auf Basis einer Keyset-Abfrage — sie liefert nahezu konstante Abfragezeiten unabhängig von der Datenmenge und bleibt stabil, wenn Zeilen zwischen Seitenabrufen eingefügt oder gelöscht werden. Offset-Paginierung sollte nur dann eingesetzt werden, wenn Benutzer direkt zu einer beliebigen Seitennummer springen müssen, der Datensatz klein und selten aktualisiert wird oder eine Gesamtseitenanzahl eine zwingende UI-Anforderung darstellt. Die Entscheidung hängt von zwei Fehlerszenarien ab, denen Offset-Paginierung strukturell nicht entkommen kann: linearer Leistungsabfall bei tiefen Offsets und ein Verschiebungsfehler, der Einträge dupliziert oder überspringt, wenn sich die Datenbasis während der Traversierung ändert. Dieser Artikel beschreibt jedes Muster der Familie — Offset, Cursor, Keyset, Page Tokens und Relay-style GraphQL Connections — mit ausführbarem TypeScript und dem zugehörigen SQL.
Wichtige Erkenntnisse
- Cursor und Keyset sind keine Synonyme: Ein Cursor ist das opake Token, das Ihre API an den Client zurückgibt; Keyset ist die SQL-Technik — ein zusammengesetztes
WHERE (sort_col, id) < (val, id)Prädikat — das Cursor-Paginierung durch einen Index-Seek statt eines zeilenweisen Scans effizient macht. - Eine Abfrage mit
OFFSET 500000 LIMIT 20überspringt keine 500.000 Zeilen; PostgreSQL liest und verwirft sie, bevor es 20 zurückgibt — die Abfragezeit wächst daher mit dem Offset-Wert, unabhängig von Indizes auf der Sortierspalte. - Wenn Zeilen zwischen Seitenabrufen am Anfang eines Feeds eingefügt werden, verschiebt Offset-Paginierung das Fenster: Der Client erhält einen doppelten Eintrag und überspringt stillschweigend einen anderen, ohne Fehlermeldung in der Antwort.
- Die Rückgabe von
total_countin jeder Antwort erzwingt eine separateCOUNT(*)-Abfrage; bevorzugen Sie stattdessenhas_next_page, das kostengünstig durch Abrufen vonlimit + 1Zeilen und Prüfung auf die Extrazeile ermittelt wird. - Ein Cursor kodiert eine Position in einem bestimmten geordneten, gefilterten Ergebnissatz und muss daher verworfen und auf die erste Seite zurückgesetzt werden, sobald sich Sortier- oder Filterparameter ändern.
Warum die Paginierungsstrategie entscheidend ist: Zwei Fehlerszenarien
Die Wahl der Paginierungsstrategie ist entscheidend, weil der naive Standard — LIMIT und OFFSET — zwei strukturelle Fehlerszenarien aufweist, die erst bei großen Datenmengen oder unter gleichzeitigen Schreibvorgängen auftreten. Das erste betrifft die Performance: Tiefe Offsets zwingen die Datenbank, Zeilen zu lesen und zu verwerfen. Das zweite betrifft die Konsistenz: Offsets sind positionsbasiert, sodass jedes Einfügen oder Löschen zwischen Seitenabrufen das Fenster verschiebt und den Ergebnissatz korrumpiert. Keiner dieser Fehler tritt bei einer kleinen, statischen Tabelle auf — weshalb Offset-Paginierung in der Entwicklung problemlos funktioniert und erst in der Produktion versagt.
Hier ist der Vergleich, den der Rest des Artikels begründet:
| Muster | Zufälliger Seitenzugriff | Stabil bei Schreibvorgängen | DB-Performance bei Tiefe | Gesamtanzahl | Am besten geeignet für |
|---|---|---|---|---|---|
| Offset / Limit | Ja | Nein | Nimmt linear ab | Günstig (extra Abfrage) | Kleine statische Daten, Admin-Tabellen, Such-UIs mit Seitennummern |
| Cursor (API-Oberfläche) | Nein | Ja | Konstant mit Keyset | Weglassen; has_more verwenden | Infinite Scroll, Feeds, öffentliche Listen-APIs |
| Keyset (DB-Technik) | Nein | Ja | Konstant (Index-Seek) | Schwierig | Die Implementierung unter einer Cursor-API |
| Page Token | Nein | Ja | Implementierungsabhängig | Optional | APIs, die ihre Strategie verbergen und weiterentwickeln möchten |
| Relay Connection | Nein | Ja | Konstant mit Keyset | Optional | GraphQL-Clients, die die Connections-Spezifikation verwenden |
Die beiden rechten Eigenschaften — Performance und Stabilität — sind das, was die Muster voneinander unterscheidet. Alles Folgende erklärt, warum.
Offset-Paginierung: Wie der Verschiebungsfehler entsteht
Discover how at OpenReplay.com.
Offset-Paginierung verwendet page und limit (oder offset und limit), um eine numerische Position zu berechnen: offset = (page - 1) * limit. Es ist das einzige Muster, das das direkte Springen zu einer beliebigen Seitennummer unterstützt, was es zur richtigen Wahl für Admin-Tabellen und Suchergebnisse macht, bei denen Benutzer direkt auf „Seite 7” klicken. Seine Schwächen sind beide Konsequenzen dieser positionsbasierten Adressierung.
Die Leistungsschwäche ist dokumentiertes Verhalten, kein Implementierungsdetail. Laut der PostgreSQL-Dokumentation zu LIMIT und OFFSET „müssen die durch eine OFFSET-Klausel übersprungenen Zeilen dennoch serverseitig berechnet werden.” Eine Abfrage mit OFFSET 500000 LIMIT 20 überspringt keine 500.000 Zeilen — die Datenbank liest und verwirft sie, bevor sie 20 zurückgibt, was bedeutet, dass die Abfragezeit mit dem Offset-Wert wächst, unabhängig von Indizes auf der Sortierspalte. Diese Semantik ist über aktuelle PostgreSQL-Hauptversionen (16 bis 18) hinweg unverändert.
Die Konsistenzschwäche ist der Verschiebungsfehler. Wenn Zeilen am Anfang eines Feeds zwischen Seitenabrufen eingefügt werden, verschiebt Offset-Paginierung das Fenster: Der Eintrag an Position offset auf Seite N+1 ist derselbe, der auf Seite N an Position offset - 1 stand — der Client erhält also einen Duplikat, und der Eintrag, der aus dem Grenzbereich herausgefallen ist, wird stillschweigend übersprungen, ohne Fehlermeldung in der Antwort. Eine Löschung erzeugt das Spiegelbild: Das Fenster schrumpft, und ein Eintrag wird übersprungen.
Hier ist ein korrekter Offset-Endpunkt in TypeScript mit Express und pg 8.22.x, ausgeführt auf Node 24 (Active LTS):
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";
const pool = new Pool();
const app = express();
interface OffsetPage<T> {
data: T[];
pagination: {
page: number;
limit: number;
has_next_page: boolean;
};
}
function clampLimit(raw: unknown): number {
const n = Number(raw) || 20;
return Math.min(Math.max(Math.trunc(n), 1), 100); // clamp 1–100
}
app.get("/posts", async (req, res) => {
const limit = clampLimit(req.query.limit);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
// Fetch limit + 1 to learn has_next_page without a COUNT query
const { rows } = await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2`,
[limit + 1, offset]
);
const has_next_page = rows.length > limit;
const data = rows.slice(0, limit);
const body: OffsetPage<typeof data[number]> = {
data,
pagination: { page, limit, has_next_page },
};
res.json(body);
});
Zwei Details sind hier besonders relevant. Die Funktion clampLimit begrenzt die Seitengröße auf 100, sodass ein Client nicht limit=1000000 anfordern und den Server überlasten kann. Und das Abrufen von limit + 1 Zeilen ermöglicht es dem Endpunkt, has_next_page ohne eine zweite COUNT(*)-Abfrage zu ermitteln — derselbe Trick, den die Cursor-Implementierung weiter unten verwendet. Wenn die Extrazeile existiert, gibt es mindestens eine weitere Seite.
Cursor-Paginierung: Die API-Oberfläche
Cursor-Paginierung ersetzt den numerischen Offset durch ein opakes Token, das eine feste Position im Ergebnissatz markiert. Der Client sendet das Token zurück, um die nächste Seite abzurufen. Da das Token an den tatsächlichen Sortierwerten einer Zeile verankert ist statt an einer Zählung, korrumpieren Einfügungen und Löschungen anderswo in der Tabelle das Fenster nicht. Der Kompromiss ist der Verlust des zufälligen Seitenzugriffs: Es gibt keine „Seite 7”, nur „die Seite nach diesem Cursor.”
Cursor und Keyset sind keine Synonyme, und ihre Verwechslung ist der häufigste Fehler in Texten zur Paginierung. Ein Cursor ist das opake Token, das Ihre API an den Client zurückgibt und die Paginierungsstrategie verbirgt; Keyset ist die SQL-Technik — ein zusammengesetztes WHERE (sort_col, id) < (val, id) Prädikat — das Cursor-Paginierung durch einen Index-Seek statt eines zeilenweisen Scans effizient macht. Sie können eine Cursor-API mit einer Offset-Abfrage darunter implementieren (nicht empfohlen — sie erbt den Leistungsabfall bei tiefen Offsets) oder mit einer Keyset-Abfrage darunter (empfohlen). Der Cursor ist die Schnittstelle; Keyset ist der effiziente Motor.
Das Token selbst ist lediglich eine kodierte Position — typischerweise die Sortierspalte und ein Tiebreaker, base64-kodiert, damit Clients es als opak behandeln und sich nicht auf seine interne Struktur verlassen. Halten Sie es opak, damit Sie später den Inhalt ändern können, ohne Clients mitten in einer Traversierung zu unterbrechen.
// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
data: T[];
pagination: {
next_cursor: string | null;
has_more: boolean;
};
}
type Cursor = { created_at: string; id: number };
function encodeCursor(c: Cursor): string {
return Buffer.from(JSON.stringify(c)).toString("base64url");
}
function decodeCursor(raw: string): Cursor {
return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}
app.get("/feed", async (req, res) => {
const limit = clampLimit(req.query.limit);
const cursor = req.query.cursor
? decodeCursor(String(req.query.cursor))
: null;
// Keyset predicate: compound row comparison on the sort tuple
const { rows } = cursor
? await pool.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor.created_at, cursor.id, limit + 1]
)
: await pool.query(
`SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1`,
[limit + 1]
);
const has_more = rows.length > limit;
const data = rows.slice(0, limit);
const last = data[data.length - 1];
const body: CursorPage<typeof data[number]> = {
data,
pagination: {
has_more,
next_cursor:
has_more && last
? encodeCursor({ created_at: last.created_at, id: last.id })
: null,
},
};
res.json(body);
});
Der Endpunkt dekodiert den Cursor in ein (created_at, id)-Tupel, führt eine Keyset-Abfrage dagegen aus, ruft limit + 1 ab, um has_more zu berechnen, und kodiert den nächsten Cursor aus der zuletzt zurückgegebenen Zeile. Kein COUNT(*), kein Offset, kein Scan verworfener Zeilen.
Keyset-Paginierung: Das zusammengesetzte Prädikat
Keyset-Paginierung ist die Datenbanktechnik, die Cursor-Paginierung effizient macht: Statt eines Offsets filtert sie auf dem Sortierschlüssel selbst mit einem zusammengesetzten Prädikat. PostgreSQL unterstützt Reihenwertvergleiche, sodass (created_at, id) < ($1, $2) das Tupel lexikografisch vergleicht und einem passenden zusammengesetzten Index ermöglicht, direkt zur Grenze zu springen.
Der Tiebreaker ist nicht optional. created_at allein ist nicht eindeutig — zwei Posts können denselben Zeitstempel auf die Millisekunde teilen — und eine Sortierung auf einer nicht eindeutigen Spalte erzeugt eine nichtdeterministische Reihenfolge, was bedeutet, dass ein an created_at verankerter Cursor Zeilen mit demselben Wert überspringen oder wiederholen könnte. Das Anhängen des Primärschlüssels (id) macht das Sortiertupel eindeutig und die Grenze deterministisch.
-- PostgreSQL 18
-- Composite index matching the sort tuple, including its direction.
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);
-- Keyset query: compound predicate seeks into the index.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
Die Indexrichtung ist entscheidend. Laut PostgreSQL-Dokumentation zu Indizes und ORDER BY kann ein B-Tree-Index in beide Richtungen gescannt werden, aber die Übereinstimmung der deklarierten Indexreihenfolge mit dem ORDER BY der Abfrage ermöglicht es dem Planer, sowohl das Prädikat als auch die Sortierung aus einem einzigen Index-Scan zu erfüllen. Führen Sie EXPLAIN ANALYZE für beide Varianten auf einer großen Tabelle aus, und der strukturelle Unterschied ist im Ausführungsplan sichtbar: Die Abfrage mit OFFSET 500000 zeigt, wie der Server die führenden Zeilen berechnet und verwirft, während die Keyset-Abfrage einen Index-Scan mit geringen Anlaufkosten zeigt, der nur die benötigten Zeilen zurückgibt. Der Kostenunterschied ist strukturell, nicht zufällig — er folgt direkt aus der oben beschriebenen dokumentierten OFFSET-Semantik.
Page Tokens: Die Strategie verbergen
Page Tokens sind die Variante der Cursor-Paginierung mit opaker Strategie, am saubersten beschrieben in den API-Designrichtlinien von Google Cloud. Gemäß AIP-158 überträgt die Anfrage page_size und page_token, die Antwort gibt einen next_page_token zurück, und die Spezifikation verlangt, dass Page Tokens für den Client opak sind. Die serverseitige Kodierung des vollständigen Paginierungszustands ermöglicht es dem Server, seine interne Strategie zu ändern — beispielsweise von Offset zu Keyset — ohne Clients zu unterbrechen, die sich mitten in einer Traversierung befinden.
Stripe wird häufig zusammen mit Google in diesem Zusammenhang genannt, aber die beiden Mechanismen sind nicht identisch, und die Unterscheidung lohnt sich. Laut der Stripe-Paginierungsdokumentation verwendet Stripes List-API Objekt-ID-Cursor (starting_after und ending_before, mit limit und einem has_more-Flag), keine opaken Page Tokens; die Search-API ist diejenige, die ein opakes next_page-Token zurückgibt. Die präzise Übersicht lautet daher: Google Cloud verwendet opake Page Tokens, Stripes List-Endpunkte verwenden Objekt-ID-Cursor, Shopify verwendet Cursor-basierte Connections über seine GraphQL Admin API, und GitHubs REST-API paginiert über den Link-Header — seitennummernbasiert bei den meisten Endpunkten, mit Cursor-basiertem (before/after) nur bei bestimmten. Der gemeinsame Nenner ist Cursor-artige Traversierung, nicht ein einheitlicher Mechanismus.
Relay-Style GraphQL Connections
Relay Connections sind die GraphQL-Spezialisierung der Cursor-Paginierung, formalisiert so, dass jedes Listen-Feld in einem Schema auf dieselbe Weise paginiert. Die Relay Cursor Connections Specification definiert eine Connection als eine Liste von edges, wobei jede Edge einen node (den Eintrag) und einen cursor (seine opake Position) hat, sowie ein pageInfo-Objekt, das hasNextPage, hasPreviousPage, startCursor und endCursor bereitstellt.
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Der Cursor pro Edge ist dasselbe opake Token-Konzept aus den REST-Abschnitten, und pageInfo.hasNextPage ist dasselbe has_more-Flag, das durch den Fetch-one-extra-Trick abgeleitet wird. Die Struktur ist schwerer als ein einfaches { data, pagination }-Envelope, gibt Clients aber einen einheitlichen Vertrag: Jede Connection durch Übergabe von first/after und Lesen von pageInfo paginieren. Intern sollte eine Relay Connection dennoch durch eine Keyset-Abfrage unterstützt werden — aus denselben Leistungsgründen.
Ein Muster wählen und häufige Fallstricke vermeiden
Setzen Sie standardmäßig auf Cursor-Paginierung mit Keyset für jeden neuen Listen-Endpunkt, und greifen Sie auf Offset nur zurück, wenn eine spezifische Anforderung es erzwingt. Die Entscheidungsmatrix:
| Anwendungsfall | Muster |
|---|---|
| Infinite-Scroll-Feed, Aktivitätsstrom, Benachrichtigungen | Cursor + Keyset |
| Öffentliche Listen-API, die später neu implementiert werden könnte | Page Token (opak) |
| GraphQL-Listen-Feld | Relay Connection (Keyset darunter) |
| Massenexport einer großen Tabelle | Cursor + Keyset |
| Admin-Tabelle mit „Zu Seite N springen” | Offset |
| Such-UI mit Seitennummern | Offset |
| Kleine (<10k Zeilen), selten geänderte Referenzdaten | Offset (beides ist vertretbar) |
Eine Reihe von Praktiken hält alle diese Muster in der Produktion korrekt:
- Seitengröße begrenzen. Begrenzen Sie
limitauf ein festes Maximum (100 ist eine gängige Obergrenze), damit ein Client keine unbegrenzte Seite anfordern kann. - Das vollständige Sortiertupel indizieren. Der zusammengesetzte Index muss sowohl Spalten als auch Richtungen des
ORDER BYabdecken, sonst fällt die Keyset-Abfrage auf einen Scan zurück. has_next_pagestatt Gesamtanzahl bevorzugen. Die Rückgabe vontotal_countin jeder Antwort erzwingt eine separateCOUNT(*)-Abfrage, deren Kosten bei einer großen Tabelle von Table Bloat und der Visibility Map abhängen; leiten Siehas_next_pagestattdessen kostengünstig aus derlimit + 1-Zeile ab und behalten Sie Gesamtanzahlen für UIs vor, die sie wirklich benötigen.- Cursor opak halten. Kodieren Sie sie, damit Clients sich nicht auf das interne Format verlassen können, und Sie die zugrunde liegende Strategie frei ändern können.
- Cursor bei Filter- oder Sortierungsänderung zurücksetzen. Ein Cursor kodiert eine Position in einem bestimmten geordneten, gefilterten Ergebnissatz; wenn der Benutzer eine Sortierreihenfolge ändert oder einen neuen Filter anwendet, ist dieser Cursor für die neue Abfrage ungültig und muss verworfen werden — setzen Sie ihn auf die erste Seite zurück, sobald sich Sortier- oder Filterparameter ändern. Im Client bedeutet das, den Cursor-Zustand an den Filter-Zustand zu koppeln, sodass eine Filteränderung die angesammelten Seiten löscht und neu lädt.
Der Verschiebungsfehler und der Leistungsabfall bei tiefen Offsets sind beim Testen leicht zu übersehen, da sie nur bei gleichzeitigen Schreibvorgängen oder bei großer Tiefe auftreten, und mehrere davon nie in Ihren Fehlerprotokollen auftauchen. Der Verschiebungsfehler ist in Server-Logs unsichtbar — die Antwort ist ein gültiges 200 mit der korrekten Anzahl an Einträgen — aber im Session Replay zeigt er sich als spezifisches Rendering-Artefakt: Ein Eintrag, der bereits im Viewport sichtbar war, erscheint im nächsten Batch erneut, unmittelbar neben seiner vorherigen Position. Der Leistungsabfall bei tiefen Offsets zeigt sich auf dieselbe Weise: nicht als Timeout oder 4xx, sondern als „Mehr laden”-Spinner, der sich bei einem langsamen 200 nie auflöst, auf den der Client noch wartet. Da es sich um Symptome handelt, die sich über Interaktionssequenzen erstrecken und keine diskreten Fehler sind, werden sie häufig über Session Replay sichtbar — sie leben in der vollständigen Abfolge von Scrollen, Abrufen und Rendern, nicht in einer einzelnen fehlgeschlagenen Anfrage.
Fazit
Greifen Sie beim nächsten Listen-Endpunkt, den Sie entwickeln, auf Cursor-Paginierung mit einer Keyset-Abfrage zurück, und behandeln Sie Offset als die Ausnahme, die Sie begründen müssen, statt als den Standard, den Sie erben — sein linearer Leistungsabfall bei tiefen Offsets und seine Verschiebungsinstabilität sind strukturell, nicht konfigurierbar. Fügen Sie den zusammengesetzten Index auf Ihrem Sortiertupel hinzu, geben Sie has_next_page statt einer Gesamtanzahl zurück, und halten Sie den Cursor opak, damit Sie die zugrunde liegende Engine austauschen können, ohne eine Breaking Change einzuführen.
FAQs
Kann ich Cursor-Paginierung zu einer bestehenden Offset-basierten API hinzufügen, ohne Clients zu unterbrechen?
Ja, aber behandeln Sie es als neuen Parameter statt als Austausch. Akzeptieren Sie einen Cursor-Abfrageparameter neben den bestehenden page- und limit-Parametern, und leiten Sie Anfragen, die einen Cursor enthalten, zur Keyset-Abfrage weiter, während seitenbasierte Anfragen auf dem Offset-Pfad bleiben. Halten Sie den Cursor opak, damit das interne Format frei änderbar bleibt, und dokumentieren Sie den Offset-Pfad als veraltet für große oder schreibintensive Endpunkte, anstatt ihn sofort zu entfernen.
Was passiert mit einem Cursor, wenn die Zeile, auf die er zeigt, gelöscht wird?
Es tritt kein Fehler auf, da ein Keyset-Cursor Sortierwerte kodiert statt einer Zeilenreferenz. Das zusammengesetzte Prädikat (created_at, id) < (val, id) wählt alle Zeilen aus, die nach diesem Grenz-Tupel geordnet sind, sodass die nächste Seite korrekt zurückgegeben wird, auch wenn die genaue Ankerzeile nicht mehr existiert. Dies ist der Kernvorteil gegenüber Offset-Paginierung, bei der eine Löschung jede nachfolgende Position verschiebt und dazu führt, dass der Client einen Eintrag stillschweigend überspringt.
Warum base64-kodiertes JSON für einen Cursor verwenden statt Zeitstempel und ID direkt zu senden?
Die Kodierung macht den Cursor opak, was Clients signalisiert, dass das Token nicht manuell geparst oder konstruiert werden soll. Wenn Clients den rohen Zeitstempel und die ID lesen, koppeln sie sich an Ihre interne Paginierungsstrategie und brechen, wenn Sie diese ändern. Ein opakes Base64-Token ermöglicht es Ihnen, Felder hinzuzufügen, Sortierschlüssel zu wechseln oder von Keyset zu Page Tokens zu wechseln, ohne eine clientseitige Änderung. Der Kodierungsaufwand ist vernachlässigbar.
Funktioniert Cursor-Paginierung auch bei der Sortierung nach einer anderen Spalte als created_at, beispielsweise einem Popularitätswert?
Ja, sofern der Cursor dasselbe Sortiertupel kodiert, nach dem die Abfrage ordnet, und mit einem eindeutigen Tiebreaker endet. Die Sortierung nach einem Popularitätswert erfordert, dass Prädikat und Index (score, id) abdecken und der Cursor beide Werte enthält, da Scores weit häufiger kollidieren als Zeitstempel. Ohne den eindeutigen id-Tiebreaker erzeugen Zeilen mit demselben Score eine nichtdeterministische Reihenfolge, die seitenübergreifend Einträge überspringen oder wiederholen kann.