Warum jede Collection paginiert sein muss
Ein Listen-Endpoint ohne Limit ist ein Versprechen, das man nicht halten kann. Der Code mag am Tag des Releases korrekt funktionieren, wenn die Tabelle nur ein paar hundert Zeilen enthält, wird aber still und heimlich zu einem Risiko, sobald die Tabelle wächst. Ein einziger SELECT * über eine Tabelle mit einer Million Zeilen wird dutzende Megabyte serialisieren, im Speicher halten und an einen Client streamen, der wahrscheinlich nur die ersten zwanzig Einträge wollte.
Das Scheitern erfolgt nicht graduell. Ab einer bestimmten Tabellengröße überschreitet der Endpoint einen Schwellenwert: Der Speicherverbrauch schießt in die Höhe, der Event Loop blockiert, Requests laufen in einen Timeout, Retries häufen sich an und eine einzige langsame Route reißt den gesamten Service mit in den Abgrund. Ein nicht authentifizierter Angreifer benötigt dafür nicht einmal einen Bug, sondern lediglich eine URL.
Die Lösung ist eine strikte Begrenzung für jede Collection:
- Eine Standard-Seitengröße (default page size), damit Clients ohne weiteres eine nützliche Antwort erhalten.
- Eine maximale Seitengröße (maximum page size), damit kein Client den gesamten Datensatz anfordern kann.
- Eine stabile Sortierung, damit Seiten sich nicht überschneiden oder Einträge übersprungen werden.
- Ein Continuation Token, damit der Client den nächsten Ausschnitt anfordern kann.
Selbst Endpoints, von denen man glaubt, dass sie klein bleiben, verdienen ein Limit. Tabellen wachsen, Importe finden statt, und der Endpoint, den Sie heute absichern, ist derjenige, der morgen noch läuft.
Offset-Pagination und ihre Schwachstellen
Die bekannteste Form ist LIMIT mit OFFSET: Gib limit Zeilen zurück, beginnend nach offset Zeilen.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
Sie ist leicht zu verstehen, lässt sich natürlich auf Seitenzahlen (page * limit) abbilden und ermöglicht es dem Client, zu einer beliebigen Seite zu springen. Für kleine, sich langsam ändernde Tabellen ist sie völlig ausreichend. Für alles andere gibt es drei Probleme, die mit wachsenden Datenmengen zunehmen.
Tiefe Seiten sind teuer. Um die Zeilen nach einem Offset zurückzugeben, muss die Datenbank zuerst jede vorangegangene Zeile generieren und dann verwerfen. OFFSET 1000000 liest eine Million Zeilen, um zwanzig zurückzugeben. Der Aufwand wächst linear mit der Seitenzahl, sodass Seite 1 schnell ist, Seite 50.000 hingegen nicht.
Das Fenster verschiebt sich. Ein Offset ist eine Position in einer Liste, die sich ständig ändert. Wenn eine Zeile vor Ihrer Position eingefügt wird, wiederholt die nächste Seite ein Element; wird eine Zeile gelöscht, wird ein Element übersprungen. Der Client sieht Duplikate und Lücken, und keine Anzahl an Wiederholungsversuchen behebt dies.
Die Sortierung kann instabil sein. Wenn der Sortierschlüssel Gleichstände aufweist, wie es bei created_at oft der Fall ist, kann die Datenbank die gleichwertigen Zeilen in beliebiger Reihenfolge zurückgeben. Zwei Anfragen für denselben Offset können unterschiedliche Ergebnisse liefern.
Offset ist nicht per se falsch, es ist lediglich ungeeignet für große oder schnelllebige Sammlungen. Nutzen Sie es für Admin-Tabellen mit Seitenzahlen bei moderaten Datenmengen und greifen Sie zu einem Cursor, wenn die Liste wachsen kann.
Cursor-Pagination: Von Natur aus stabil
Ein Cursor ersetzt das Prinzip „überspringe N Zeilen“ durch „beginne nach dieser Zeile“. Der Client sendet einen opaken Token, den der Server erzeugt hat, und der Server wandelt diesen zurück in eine präzise Position innerhalb der Sortierung.
Da der Token eine bestimmte Zeile benennt und nicht eine bloße Anzahl, können Einfügungen oder Löschungen an anderen Stellen im Ergebnissatz die Position nicht verschieben. Da der Server direkt zu dieser Zeile springt, verursacht die Tiefe der Pagination keine zusätzlichen Kosten. Diese beiden Garantien – Stabilität und konstante Kosten – sind genau die Punkte, die ein Offset nicht bieten kann.
Der Preis dafür ist, dass ein Cursor sich nur vorwärts oder rückwärts bewegen kann. Es gibt keine „Seite 50“. Für Feeds, Timelines, Exporte und Infinite Scroll ist das keinerlei Nachteil. Für ein Suchergebnis-Grid mit nummerierten Seiten bleibt Offset die natürliche Wahl, solange die Datenmenge nicht zu groß wird.
Ein Cursor sollte für Clients opak sein: base64url-kodiert, als Blackbox behandelt und unverändert zurückgegeben. Diese Opazität ermöglicht es dem Server, die Sortierspalten später zu ändern, ohne die Clients zu beeinträchtigen, und verhindert, dass Tokens manuell manipuliert werden.
Keyset Pagination in SQL
Keyset Pagination ist im Grunde das, was ein Cursor auf Datenbankebene macht. Anstatt Zeilen zu zählen, vergleichen Sie die Sortierspalten mit den Werten der letzten zurückgegebenen Zeile.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
Die Form (created_at, id) < ($1, $2) ist ein Row-Value-Vergleich und drückt die Logik präzise aus: Nehmen Sie Zeilen, deren Sortierschlüssel strikt nach dem letzten liegt. Postgres und die meisten relationalen Datenbanken können einen Index verwenden, der der Sortierung entspricht, um direkt zum Startpunkt zu springen.
Dieser Index ist nicht optional. Keyset Pagination ist nur dann schnell, wenn die Sortierung durch einen Index über genau den Spalten und Richtungen abgesichert ist, die in ORDER BY verwendet werden.
CREATE INDEX posts_created_id_idx
ON posts (created_at DESC, id DESC);
Zwei Details führen zu den meisten Fehlern. Erstens muss die Vergleichsrichtung mit der Sortierung übereinstimmen: Eine DESC-Sortierung verwendet <, eine ASC-Sortierung verwendet >. Zweitens muss der Cursor jede Sortierspalte enthalten. Wenn Sie nur nach created_at sortieren, führen identische Werte dazu, dass der Cursor mehrdeutig wird – weshalb id immer angehängt wird.
Einen Cursor erstellen und kodieren
Ein Cursor ist im Grunde nur der serialisierte und kodierte Sortierwert der letzten Zeile auf einer Seite. Halten Sie ihn klein und validieren Sie ihn bei der Rückgabe.
export type Cursor = { createdAt: string; id: string };
export function encodeCursor(cursor: Cursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}
export function decodeCursor(token: string): Cursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
} catch {
throw new Error("invalid_cursor");
}
const value = parsed as Record<string, unknown>;
if (typeof value.createdAt !== "string" || typeof value.id !== "string") {
throw new Error("invalid_cursor");
}
return { createdAt: value.createdAt, id: value.id };
}
Der „Limit-plus-one“-Trick verrät Ihnen, ob eine weitere Seite existiert, ohne dass ein Count-Query nötig ist. Rufen Sie limit + 1 Zeilen ab; wenn Sie mehr als limit erhalten, gibt es eine nächste Seite. Die zusätzliche Zeile wird vor der Antwort wieder entfernt.
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
);
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data.at(-1);
const nextCursor = hasMore && last
? encodeCursor({ createdAt: last.created_at, id: last.id })
: null;
Base64url ist eine Kodierung, keine Signatur. Ein Client kann sie dekodieren und einen neuen Cursor erstellen, behandeln Sie einen Cursor daher niemals als vertrauenswürdigen Input. Validieren Sie jedes Feld. Falls Manipulationsschutz wichtig ist, signieren Sie den Payload mit einem HMAC oder verwalten Sie die Sortierschlüssel serverseitig und speichern Sie den Cursor in Redis.
Der paginierte Response-Envelope
Geben Sie von jedem Collection-Endpoint eine konsistente Struktur zurück. Clients haben so ein einheitliches Muster für das Parsing, und Sie können die internen Abläufe anpassen, ohne den Contract zu ändern.
{
"data": [
{ "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
"hasMore": true,
"total": 1284
}
}
data enthält die Seite und überschreitet niemals das Limit. nextCursor ist der Token für die folgende Seite oder null, wenn die Liste erschöpft ist – dies ist das Signal für den Infinite Scroll, zu stoppen. hasMore ist eine Komfortfunktion, die Clients die Prüfung auf null erspart und sich aus dem Limit-plus-one-Fetch kostenlos ergibt.
total ist optional und sollte auch so behandelt werden. Eine Cursor-Seite benötigt dies nicht, und die Berechnung bei jeder Anfrage ist oft teurer als die Seite selbst. Fügen Sie es nur hinzu, wenn die UI tatsächlich „1.284 Ergebnisse“ anzeigt, und cachen oder approximieren Sie den Wert dann.
Sortierstabilität und Tie-Breaker
Ein Cursor ist nur dann sinnvoll, wenn die Sortierung eine totale Ordnung darstellt: Für zwei beliebige Zeilen muss definitiv feststehen, welche vor der anderen kommt. Die meisten natürlichen Sortierschlüssel erfüllen dies nicht. Viele Posts teilen sich denselben created_at, und die Datenbank kann sie in beliebiger Reihenfolge zurückgeben. Ein Cursor, der nur den Zeitstempel kodiert, kann daher Zeilen überspringen oder doppelt ausgeben.
Die Lösung besteht darin, eine eindeutige Spalte – fast immer den Primary Key – als letzten Sortierschlüssel anzuhängen.
ORDER BY created_at DESC, id DESC
Nun ist die Reihenfolge deterministisch und der Cursor (created_at, id) ist eindeutig. Dieselbe Regel gilt für jede Sortierung: ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. Der Tie-Breaker muss eindeutig sein und sowohl Teil des Index als auch des Cursors.
Die Sortierschlüssel müssen zudem über die Zeit stabil sein. Die Sortierung nach updated_at und die Verwendung dieses Feldes in einem Cursor ist eine Falle: Wenn eine Zeile bearbeitet wird, verschiebt sich ihre Sortierposition. Ein vor der Bearbeitung erfasster Cursor kann dann auf die falsche Stelle verweisen. Bevorzugen Sie unveränderliche Schlüssel wie created_at oder eine monotone ID. Wenn Sie zwingend nach einem veränderbaren Feld sortieren müssen, müssen Sie akzeptieren, dass Cursor veralten können.
Gesamtzahlen sind teuer
Die Gesamtzahl der Datensätze ist der am häufigsten angeforderte und gleichzeitig am wenigsten notwendige Teil der Pagination. SELECT count(*) mit einem Filter muss jede passende Zeile prüfen, was bei einer großen Tabelle länger dauern kann als das Abrufen der eigentlichen Seite.
-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;
Eine Cursor-Seite benötigt dies nicht. hasMore beantwortet die Frage, die der Client tatsächlich hat – „Gibt es noch mehr?“ – ohne eine einzige zusätzliche Zeile anzufassen. Wenn die UI wirklich eine Zahl benötigt, wählen Sie einen Ansatz, der zur tolerierbaren Genauigkeit passt:
- Weglassen. Die meisten Infinite-Scroll- und Feed-Interfaces zeigen niemals eine Gesamtzahl an.
- Annähern. Postgres stellt
reltuplesüberpg_classbereit und der Planner kann mitEXPLAINschätzen; beides ist schnell und liegt nur um wenige Prozent daneben. - Cachen. Berechnen Sie die Anzahl nach einem Zeitplan oder nach Schreibvorgängen und liefern Sie den gespeicherten Wert aus.
- Einen Zähler pflegen. Führen Sie eine laufende Zählung in einer Summary-Tabelle, die innerhalb derselben Transaction wie die Schreibvorgänge aktualisiert wird.
- Begrenzen. Stoppen Sie das Zählen bei 1.000 und geben Sie „1000+“ zurück, wodurch die Kosten begrenzt werden.
Egal wofür Sie sich entscheiden: Führen Sie bei einer großen Tabelle niemals ein nicht gecachtes count(*) bei jeder Seitenanfrage aus.
Seitengröße: Standardwerte und Limits
Zwei Zahlen schützen den Server: ein Standardwert, wenn der Client nichts angibt, und ein hartes Maximum, wenn der Client zu viel anfordert.
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
function parseLimit(raw: string | undefined): number {
const requested = Number.parseInt(raw ?? "", 10);
if (!Number.isFinite(requested) || requested < 1) return DEFAULT_LIMIT;
return Math.min(requested, MAX_LIMIT);
}
Kappen statt Ablehnen. Ein Client, der limit=1000 anfordert, sollte 100 Zeilen und einen Cursor erhalten, anstatt eines 400-Fehlers, der ihn zwingt, deine Regeln zu erraten. Validiere, dass limit eine positive Ganzzahl ist, und leite einen String vom Client niemals direkt an SQL weiter.
Die Seitengröße ist ein Regler für die Latenz. Größere Seiten bedeuten weniger Round-trips, aber mehr Arbeit pro Request und mehr Bytes auf der Leitung. Für interaktive UIs sind 20 bis 50 in der Regel angemessen. Für Bulk-Exporte solltest du einen dedizierten Endpoint mit einem deutlich höheren Limit und Streaming verwenden, anstatt das Limit auf der interaktiven Route zu erhöhen.
Filter- und Sortierparameter
Die Paginierung wird oft mit Filtern und Sortierungen kombiniert. Beide müssen jedoch sorgfältig behandelt werden, da sie die Bedeutung eines Cursors verändern.
GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Sortierfelder auf eine Whitelist setzen. Interpolieren Sie niemals einen vom Client übermittelten Spaltennamen direkt in SQL. Mappen Sie einen Satz erlaubter Namen auf entsprechende Ausdrücke und bestimmen Sie die Sortierrichtung über ein explizites Präfix oder einen Parameter.
const SORTS = {
created_at: "created_at",
title: "title",
score: "score",
} as const;
const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";
Den Cursor an die Sortierung anpassen. Wenn sich die Sortierung ändert, ist der Cursor bedeutungslos. Kodieren Sie entweder die Sortierung in den Cursor und lehnen Sie bei einer Diskrepanz die Anfrage ab, oder integrieren Sie die Sortierung in die Payload des Cursors und verifizieren Sie diese beim Dekodieren. Das Gleiche gilt für Filter: Ein Cursor aus einer ungefilterten Liste sollte nicht gegen eine gefilterte Liste verwendet werden.
Jede Sortierspalte zum Index hinzufügen. Ein zusammengesetzter Index auf (author_id, created_at DESC, id DESC) bedient sowohl den Filter als auch den Keyset-Seek in einer einzigen Struktur. Das ist der entscheidende Unterschied, ob eine Seite in einer Millisekunde oder in einer Sekunde geladen wird.
Pagination bei Suchen und Aggregationen
Suchmaschinen und Aggregationen folgen ihren eigenen Regeln. Full-text search Backends begrenzen from + size meist auf etwa zehntausend Ergebnisse, da ein tiefer Offset auch für sie sehr kostspielig ist. Das Äquivalent zu Keyset ist hier ein search_after Token, das aus den Sortierwerten des letzten Treffers generiert wird.
POST /posts/_search
{
"size": 20,
"sort": [{ "created_at": "desc" }, { "id": "desc" }],
"search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}
Aggregationen sollten idealerweise separat von der eigentlichen Seite zurückgegeben werden. Die Berechnung von Facet-Counts für jeden Treffer bei jeder Anfrage ist dieselbe Falle wie bei count(*). Entweder berechnet man diese einmal und cached sie, oder man stellt einen dedizierten Endpoint bereit, den die UI aufruft, wenn der Benutzer ein Filterpanel öffnet.
Für SQL-Aggregate gilt dasselbe Keyset-Prinzip: Sortiere nach einem stabilen Aggregate-Key, wie etwa einem Datums-Bucket oder einer ID, und verwende diesen Key im Cursor. Paginiere niemals ein GROUP BY mit OFFSET über eine große Tabelle; materialisiere das Aggregate zuerst und paginiere dann das materialisierte Ergebnis.
Die Wahl der Strategie
Die meisten Teams benötigen nur eine einzige Regel: Wenn die Collection sehr groß werden kann oder sich während des Lesens ändern kann, verwenden Sie einen Cursor; wenn sie klein ist, sich selten ändert und als nummeriertes Grid angezeigt wird, ist Offset völlig ausreichend.
Small table, numbered UI -> offset
Large or fast-changing collection -> keyset cursor
Infinite scroll or mobile feed -> keyset cursor
Search results -> search_after token
Bulk export -> dedicated streaming endpoint
Offset und Cursor können nebeneinander existieren. Eine Admin-Tabelle bietet beispielsweise Seitenzahlen zum Durchsuchen und einen Cursor für einen „Export All“-Flow. Entscheidend ist, dass jeder Endpoint einen Stil wählt und diesen dokumentiert, anstatt page- und cursor-Parameter auf eine Weise zu mischen, die für Clients nicht vorhersehbar ist.
Rückwärts-Pagination
Die Vorwärts-Pagination bekommt meist die gesamte Aufmerksamkeit, aber viele Interfaces benötigen auch einen „Zurück“-Button. Die Technik dahinter besteht darin, den Vergleich und die Sortierung umzukehren, eine Seite abzurufen und die Zeilen anschließend im Anwendungscode wieder umzudrehen, bevor sie zurückgegeben werden.
async function pageBackward(prev: Cursor, limit: number) {
const rows = await db.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) > ($1, $2)
ORDER BY created_at ASC, id ASC
LIMIT $3`,
[prev.createdAt, prev.id, limit + 1],
);
// Reverse back into the canonical descending order.
return rows.reverse();
}
Geben Sie einen prevCursor zurück, der aus der ersten Zeile der aktuellen Seite erstellt wurde, zusammen mit einem nextCursor, der aus der letzten Zeile erstellt wurde. Ein Client, der rückwärts navigiert, wechselt wieder in die Vorwärtsrichtung, sobald der Benutzer wieder nach unten scrollt. Daher müssen die Cursor austauschbar sein und dürfen nicht an eine bestimmte Richtung gebunden sein.
Cursor-Integrität gewährleisten
Base64url ist eine Kodierung, keine Signatur. Ein Client kann einen Cursor dekodieren, bearbeiten und zurücksenden; ein Cursor ist also eine nicht vertrauenswürdige Eingabe, genau wie ein Query-Parameter. Validieren Sie jedes Feld beim Dekodieren und weisen Sie alles ab, was nicht dem erwarteten Format entspricht, bevor es die SQL-Abfrage erreicht.
Wenn Manipulationen ein ernsthaftes Risiko darstellen – zum Beispiel, wenn ein Cursor eine Tenant-ID enthält –, signieren Sie diesen mit einem HMAC und verifizieren Sie die Signatur in Constant Time.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.CURSOR_SECRET!;
export function signCursor(payload: string): string {
const mac = createHmac("sha256", secret).update(payload).digest("base64url");
return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}
export function verifyCursor(token: string): string {
const [encoded, mac] = token.split(".");
const expected = createHmac("sha256", secret).update(encoded).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("invalid_cursor");
}
return Buffer.from(encoded, "base64url").toString("utf8");
}
Eine Alternative besteht darin, Cursor vollständig serverseitig zu verwalten: Speichern Sie die Position in Redis unter einer zufälligen ID und übergeben Sie dem Client nur diese ID. Dadurch werden die Sortierspalten komplett verborgen und ein Ablaufdatum (Expiry) ist möglich, allerdings auf Kosten eines Lookups bei jeder Seite.
Ein Client, der Cursors folgt
Ein Cursor ist genau dafür gedacht, gefolgt zu werden. Daher ist der Client-Code eine einfache Schleife: eine Seite anfordern, die Daten anhängen und fortfahren, solange nextCursor nicht null ist. Es gibt keine Seiten-Arithmetik und kein Risiko, eine Seite zu überspringen.
async function fetchAll<T>(path: string): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(path, "https://api.example.com");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`request_failed_${res.status}`);
const page = await res.json();
items.push(...page.data);
cursor = page.pagination.nextCursor;
} while (cursor);
return items;
}
Die Schleife endet bei nextCursor === null, weshalb dieses Feld auf der letzten Seite zuverlässig gesetzt sein muss. Für Infinite Scroll wird dasselbe Muster Seite für Seite ausgeführt, sobald ein Sentinel-Element in den Viewport eintritt, wobei der Token im Component State statt in der URL gespeichert wird.
Pagination und der Query-Plan
Keyset-Pagination ist nur dann schnell, wenn die Datenbank einen Index verwenden kann. Bestätigen Sie dies immer mit EXPLAIN ANALYZE, anstatt es einfach vorauszusetzen.
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-16T10:00:00Z', 'post_1042')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Limit (cost=0.43..8.94 rows=20 width=40)
(actual time=0.021..0.058 rows=20 loops=1)
-> Index Scan Backward using posts_created_id_idx on posts
(cost=0.43..521.10 rows=417 width=40)
(actual time=0.019..0.051 rows=20 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2026-09-16T10:00:00Z'::timestamptz, 'post_1042'))
Execution Time: 0.081 ms
Ein Index Scan Backward mit einem Index Cond ist das gewünschte Ergebnis: Die Datenbank springt direkt zur Cursor-Position und stoppt nach zwanzig Zeilen. Ein Seq Scan mit einem Filter bedeutet, dass der Index nicht zur Sortierung passt und die Abfrage bei jeder Seite die gesamte Tabelle scannt. Der Index muss dieselben Spalten in derselben Reihenfolge und Richtung wie ORDER BY auflisten, wobei der Tie-Breaker am Ende stehen muss.
Pagination testen
Die Eigenschaften, die es zu testen gilt, sind Stabilität und Terminierung, nicht nur der Happy Path. Ein Test, der jede Seite durchläuft und sicherstellt, dass es keine Duplikate und keine fehlenden Zeilen gibt, findet jene subtilen Tie-Breaker-Bugs, die ansonsten unsichtbar bleiben.
test("cursor pagination never repeats or skips rows", async () => {
const seen = new Set<string>();
let cursor: string | null = null;
do {
const page = await request(app)
.get("/posts")
.query({ limit: 10, cursor: cursor ?? undefined })
.expect(200);
for (const post of page.body.data) {
expect(seen.has(post.id)).toBe(false);
seen.add(post.id);
}
cursor = page.body.pagination.nextCursor;
} while (cursor);
expect(seen.size).toBe(totalPosts);
});
test("rejects a malformed cursor", async () => {
await request(app).get("/posts?cursor=not-a-cursor").expect(400);
});
Testen Sie zudem die Grenzfälle: die erste Seite ohne Cursor, die letzte Seite, bei der nextCursor null ist, eine Seite, die größer als das Maximum ist, und eine Sortierung, die sich während des Durchlaufs ändert. Letzterer ist der Test, der beweist, dass Ihr Tie-Breaker funktioniert.
Best Practices
- Geben Sie jeder Collection ein Standard-Limit und ein hartes Maximum und nutzen Sie Clamping, anstatt Anfragen abzulehnen.
- Bevorzugen Sie Keyset- oder Cursor-Pagination für alles, was wächst oder sich verändert.
- Sortieren Sie immer mit einem eindeutigen Tie-Breaker wie
idund nehmen Sie diesen in den Index und den Cursor auf. - Halten Sie Cursor opak, kodieren Sie diese als base64url und validieren Sie jedes Feld beim Dekodieren.
- Rufen Sie
limit + 1ab, umhasMorezu bestimmen, anstatt einen Count auszuführen. - Geben Sie überall einen konsistenten Envelope mit
data,nextCursorundhasMorezurück. - Behandeln Sie
totalals optional; lassen Sie es weg, schätzen Sie es oder cachen Sie es. - Nutzen Sie eine Whitelist für Sortierfelder und -richtungen und binden Sie alle Werte als Parameter.
- Gestalten Sie den Cursor so, dass er den Sortier- und Filterkontext kodiert, damit ein veralteter Token nicht erneut verwendet werden kann.
Häufige Fehler
- Einen Listen-Endpunkt ohne Limit zu veröffentlichen und dies erst bei steigender Last zu bemerken.
OFFSETfür tiefe Seiten zu verwenden und zuzusehen, wie die Latenz mit der Seitenzahl ansteigt.- Nach einer nicht-eindeutigen Spalte ohne Tie-Breaker zu sortieren, sodass Zeilen wiederholt erscheinen oder verschwinden.
- Nach einer veränderbaren Spalte zu sortieren und den Cursor als permanent zu behandeln.
- Ein vom Client bereitgestelltes
limit, einen Spaltennamen oder die Sortierrichtung direkt in SQL zu übergeben. - Bei jeder Seitenanfrage ein nicht gecachtes
count(*)auszuführen. - Rohe interne IDs oder Zeitstempel in einem Cursor offenzulegen und dies als sicher zu bezeichnen.
- Ein einfaches Array ohne Cursor zurückzugeben, sodass Clients raten müssen, wie sie fortfahren sollen.
- Ein
limitvon einer Million zu erlauben, nur weil das UI dies niemals anfordert.
Wie geht es weiter?
Die Paginierung ist Teil eines vorhersagbaren API-Designs, daher ist der REST-Guide die ideale Ergänzung für Ressourcen-Strukturen, Status-Codes und Query-Konventionen. Wenn Sie vermeiden möchten, dieselbe Seite immer wieder neu zu berechnen, befasst sich Caching mit der Auslieferung aus dem Speicher, während Connection Pooling dafür sorgt, dass jede paginierte Abfrage die Datenbank wenig belastet. Um diese Abfragen von vornherein schnell zu machen, erklärt PostgreSQL die zusammengesetzten Indizes und Query-Pläne, auf denen die Keyset-Paginierung basiert.