Pourquoi chaque collection doit être paginée
Un endpoint de liste sans limite est une promesse que vous ne pourrez pas tenir. Le code peut être correct le jour de sa mise en production, quand la table ne contient que quelques centaines de lignes, pour ensuite devenir discrètement un risque à mesure que la table s’agrandit. Un seul SELECT * sur une table d’un million de lignes sérialisera des dizaines de mégaoctets, les gardera en mémoire et les transmettra à un client qui ne voulait probablement que les vingt premiers éléments.
La panne n’est pas progressive. À partir d’une certaine taille de table, l’endpoint franchit un seuil : la mémoire sature, l’event loop se bloque, les requêtes expirent, les tentatives de reconnexion s’accumulent, et une seule route lente fait tomber l’ensemble du service. Un attaquant non authentifié n’a même pas besoin d’un bug, une simple URL lui suffit.
La solution consiste à imposer une limite stricte sur chaque collection :
- Une taille de page par défaut pour que les clients reçoivent une réponse utile sans avoir à s’en soucier.
- Une taille de page maximale pour qu’aucun client ne puisse demander l’intégralité des données.
- Un ordre stable pour éviter que les pages ne se chevauchent ou ne sautent des éléments.
- Un jeton de continuation (continuation token) pour que le client puisse demander la tranche suivante.
Même les endpoints que vous pensez être petits méritent une limite. Les tables grossissent, des imports surviennent, et l’endpoint que vous protégez aujourd’hui est celui qui restera opérationnel demain.
La pagination par offset et ses limites
La forme la plus familière est LIMIT avec OFFSET : retourner limit lignes, en commençant après offset lignes.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
C’est facile à comprendre, cela correspond naturellement aux numéros de page (page * limit) et permet au client d’accéder directement à n’importe quelle page. Pour des tables de petite taille qui évoluent lentement, c’est parfaitement adéquat. Pour tout le reste, cela pose trois problèmes qui s’aggravent à mesure que les données augmentent.
Les pages profondes sont coûteuses. Pour retourner les lignes après un offset, la base de données doit d’abord générer et ignorer chaque ligne précédente. OFFSET 1000000 lit un million de lignes pour n’en retourner que vingt. La charge de travail augmente linéairement avec le numéro de la page : la page 1 est rapide, mais la page 50 000 ne l’est pas.
La fenêtre se déplace. L’offset est une position dans une liste qui évolue. Si une ligne est insérée avant votre position, la page suivante répète un élément ; si une ligne est supprimée, un élément est sauté. Le client voit des doublons et des manques, et aucune tentative de rafraîchissement ne peut corriger cela.
L’ordre peut être instable. Si la clé de tri présente des égalités, comme c’est souvent le cas avec created_at, la base de données est libre de retourner les lignes identiques dans n’importe quel ordre. Deux requêtes pour le même offset peuvent produire des résultats différents.
L’offset n’est pas une erreur, il est simplement inadapté aux collections volumineuses ou très dynamiques. Utilisez-le pour des tables d’administration avec des numéros de page sur des données modestes, et privilégiez un curseur lorsque la liste est susceptible de croître.
Pagination par curseur : stable par construction
Un curseur remplace le concept de « sauter N lignes » par « commencer après cette ligne ». Le client envoie un jeton opaque produit par le serveur, et le serveur le convertit à nouveau en une position précise dans le tri.
Comme le jeton désigne une ligne plutôt qu’un décompte, les insertions et suppressions ailleurs dans l’ensemble des résultats ne peuvent pas le décaler. Comme le serveur accède directement à cette ligne, la profondeur n’engendre aucun coût supplémentaire. Ces deux garanties — la stabilité et un coût constant — sont précisément celles que l’offset ne peut pas offrir.
Le prix à payer est qu’un curseur ne se déplace qu’en avant ou en arrière. La « page 50 » n’existe pas. Pour les flux, les timelines, les exports et le défilement infini, ce n’est absolument pas une perte. Pour une grille de résultats de recherche avec des pages numérotées, l’offset reste la solution naturelle tant que le volume de données reste modéré.
Un curseur doit être opaque pour les clients : encodé en base64url, traité comme une boîte noire et renvoyé tel quel. L’opacité permet au serveur de modifier les colonnes de tri ultérieurement sans casser les clients, et elle décourage la création de jetons manuels.
La pagination par clés (Keyset pagination) en SQL
La pagination par clés est ce qu’un curseur effectue au niveau de la base de données. Au lieu de compter, vous comparez les colonnes de tri aux valeurs de la dernière ligne retournée.
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
La forme (created_at, id) < ($1, $2) est une comparaison de valeurs de ligne, et elle exprime la logique clairement : prendre les lignes dont la clé de tri arrive strictement après la dernière. Postgres et la plupart des bases de données relationnelles peuvent utiliser un index correspondant à l’ordre pour pointer directement vers le point de départ.
Cet index n’est pas optionnel. La pagination par clés n’est rapide que lorsque le tri est supporté par un index sur les colonnes et les directions exactes utilisées dans ORDER BY.
CREATE INDEX posts_created_id_idx
ON posts (created_at DESC, id DESC);
Deux détails sont à l’origine de la plupart des bugs. Premièrement, la direction de la comparaison doit correspondre au tri : un tri DESC utilise <, un tri ASC utilise >. Deuxièmement, le curseur doit inclure chaque colonne de tri. Si vous triez par created_at seul, les égalités rendent le curseur ambigu, c’est pourquoi id est toujours ajouté.
Création et encodage d’un curseur
Un curseur n’est rien d’autre que les valeurs de tri de la dernière ligne de la page, sérialisées et encodées. Gardez-le compact et validez-le lors de sa réception.
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 };
}
L’astuce du “limit-plus-one” permet de savoir si une page suivante existe sans avoir à effectuer un count. Récupérez limit + 1 lignes ; si vous en obtenez plus de limit, c’est qu’il y a une page suivante, et vous supprimez la ligne supplémentaire avant de répondre.
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;
Le Base64url est un encodage, pas une signature. Un client peut le décoder et en fabriquer un nouveau, ne considérez donc jamais un curseur comme une entrée fiable. Validez chaque champ et, si l’intégrité est critique, signez la charge utile avec un HMAC ou conservez les clés de tri côté serveur et stockez le curseur dans Redis.
L’enveloppe de réponse paginée
Renvoyez une structure cohérente pour chaque point de terminaison de collection. Les clients n’auront ainsi qu’un seul modèle à analyser, et vous pourrez faire évoluer l’implémentation interne sans modifier le contrat d’interface.
{
"data": [
{ "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
"hasMore": true,
"total": 1284
}
}
data contient la page et ne dépasse jamais la limite. nextCursor est le jeton pour la page suivante, ou null lorsque la liste est épuisée, ce qui signale l’arrêt du défilement infini. hasMore est une commodité qui évite aux clients de vérifier la valeur null, et elle découle naturellement de la récupération de “limite + 1” sans coût supplémentaire.
total est optionnel et doit être traité comme tel. Une page basée sur un curseur n’en a pas besoin, et le calculer à chaque requête est souvent plus coûteux que la page elle-même. Ne l’incluez que lorsque l’UI affiche réellement “1 284 résultats”, et dans ce cas, mettez-le en cache ou utilisez une approximation.
Stabilité du tri et départage des égalités
Un curseur n’est pertinent que si le tri suit un ordre total : pour n’importe quelles deux lignes, l’une doit se trouver définitivement avant l’autre. La plupart des clés de tri naturelles ne le sont pas. De nombreux articles partagent le même created_at, et la base de données peut les retourner dans n’importe quel ordre ; ainsi, un curseur qui n’encode que le timestamp peut sauter ou répéter des lignes.
La solution consiste à ajouter une colonne unique, presque toujours la clé primaire, comme dernière clé de tri.
ORDER BY created_at DESC, id DESC
Désormais, l’ordre est déterministe et le curseur (created_at, id) est unique. La même règle s’applique à n’importe quel tri : ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. Le critère de départage doit être unique et doit faire partie à la fois de l’index et du curseur.
Les clés de tri doivent également être stables dans le temps. Trier par updated_at et l’utiliser dans un curseur est un piège : lorsqu’une ligne est modifiée, sa position de tri change, et un curseur capturé avant la modification peut pointer au mauvais endroit. Privilégiez des clés immuables telles que created_at ou un id monotone, et si vous devez trier par un champ mutable, acceptez que les curseurs puissent devenir obsolètes.
Les comptes totaux sont coûteux
Le compte total est l’élément le plus demandé et le moins nécessaire de la pagination. SELECT count(*) avec un filtre doit examiner chaque ligne correspondante, et sur une table volumineuse, cela peut prendre plus de temps que la récupération de la page elle-même.
-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;
Une page basée sur un curseur n’en a pas besoin. hasMore répond à la question que le client se pose réellement — « y en a-t-il d’autres ? » — sans toucher à une seule ligne supplémentaire. Si l’UI a réellement besoin d’un nombre, choisissez une approche adaptée à la précision acceptable :
- L’omettre. La plupart des interfaces de flux ou de défilement infini n’affichent jamais de total.
- L’estimer. Postgres expose
reltuplessurpg_classet le planner peut estimer avecEXPLAIN; les deux sont rapides et présentent une erreur de quelques pourcents. - Le mettre en cache. Calculez le compte selon un planning ou après des écritures, puis servez la valeur stockée.
- Maintenir un compteur. Gardez un compte actualisé dans une table de résumé, mise à jour au sein de la même transaction que les écritures.
- Le plafonner. Arrêtez de compter à 1 000 et retournez « 1000+ », ce qui limite le coût.
Quel que soit votre choix, n’exécutez pas de count(*) non mis en cache à chaque requête de page sur une table volumineuse.
Taille de page : valeurs par défaut et limites
Deux nombres protègent le serveur : une valeur par défaut lorsque le client ne précise rien, et un maximum strict lorsque le client en demande trop.
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);
}
Privilégiez le plafonnement (clamp) plutôt que le rejet. Un client demandant limit=1000 devrait recevoir 100 lignes et un curseur, et non une erreur 400 l’obligeant à deviner vos règles. Validez que limit est un entier positif et ne passez jamais une chaîne de caractères provenant du client directement dans du SQL.
La taille de la page est un curseur de latence. Des pages plus larges signifient moins d’allers-retours, mais plus de travail par requête et plus d’octets transférés. Pour les interfaces utilisateur interactives, une valeur entre 20 et 50 est généralement appropriée. Pour les exports de masse, utilisez un endpoint dédié avec un plafond beaucoup plus élevé et du streaming, plutôt que d’augmenter la limite sur la route interactive.
Paramètres de filtrage et de tri
La pagination se combine avec le filtrage et le tri, mais ces deux aspects doivent être gérés avec prudence car ils modifient la signification d’un curseur.
GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Utilisez une liste blanche pour les champs de tri. N’interpolez jamais un nom de colonne fourni par le client directement dans du SQL. Mappez un ensemble de noms autorisés vers des expressions, et déterminez la direction du tri via un préfixe ou un paramètre explicite.
const SORTS = {
created_at: "created_at",
title: "title",
score: "score",
} as const;
const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";
Faites correspondre le curseur au tri. Si le tri change, le curseur devient insignifiant. Soit vous encodez le tri dans le curseur et rejetez toute incohérence, soit vous incluez le tri dans la charge utile (payload) du curseur et le vérifiez lors du décodage. Il en va de même pour les filtres : un curseur provenant d’une liste non filtrée ne doit pas être réutilisé pour une liste filtrée.
Ajoutez chaque colonne de tri à l’index. Un index composite sur (author_id, created_at DESC, id DESC) permet de gérer à la fois le filtre et la recherche par keyset dans une seule structure, ce qui fait la différence entre une page chargée en une milliseconde et une page chargée en une seconde.
Pagination pour la recherche et les agrégations
Les moteurs de recherche et les agrégations suivent leurs propres règles. Les backends de recherche plein texte limitent généralement from + size à environ dix mille résultats, car le décalage profond (deep offset) est également coûteux pour eux. L’équivalent du keyset dans ce contexte est un jeton search_after construit à partir des valeurs de tri du dernier résultat.
POST /posts/_search
{
"size": 20,
"sort": [{ "created_at": "desc" }, { "id": "desc" }],
"search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}
Il est préférable de retourner les agrégations séparément de la page. Calculer les comptes de facettes pour chaque correspondance à chaque requête est le même piège que count(*). Soit vous les calculez une seule fois et les mettez en cache, soit vous exposez un endpoint dédié que l’UI appelle lorsque l’utilisateur ouvre un panneau de filtres.
Pour les agrégats SQL, l’idée du keyset s’applique également : triez par une clé d’agrégat stable, comme un bucket de date ou un id, et utilisez cette clé dans le curseur. Ne paginez pas un GROUP BY avec OFFSET sur une table volumineuse ; matérialisez d’abord l’agrégat, puis paginez le résultat matérialisé.
Choisir une stratégie
La plupart des équipes n’ont besoin que d’une seule règle : si la collection peut devenir volumineuse ou être modifiée pendant la lecture, utilisez un cursor ; si elle est petite, évolue peu et est affichée sous forme de grille numérotée, l’offset convient parfaitement.
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
L’offset et le cursor peuvent coexister. Un tableau d’administration peut proposer des numéros de page pour la navigation et un cursor pour un flux d’« export total ». L’important est que chaque endpoint choisisse un style et le documente, plutôt que de mélanger des paramètres page et cursor d’une manière imprévisible pour les clients.
Pagination arrière
La pagination avant attire toute l’attention, mais de nombreuses interfaces ont également besoin d’un bouton “précédent”. La technique consiste à inverser la comparaison et le tri, à récupérer une page, puis à inverser l’ordre des lignes dans le code de l’application avant de les renvoyer.
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();
}
Renvoyez un prevCursor construit à partir de la première ligne de la page actuelle, ainsi qu’un nextCursor construit à partir de la dernière. Un client naviguant vers l’arrière repasse en direction avant lorsque l’utilisateur recommence à défiler vers le bas ; les curseurs doivent donc être interchangeables plutôt que liés à une direction spécifique.
Garantir l’intégrité des curseurs
Le Base64url est un encodage, pas une signature. Un client peut décoder un curseur, le modifier et le renvoyer ; un curseur est donc une entrée non fiable, exactement comme un paramètre de requête. Validez chaque champ lors du décodage et rejetez tout ce qui ne correspond pas à la structure attendue avant que cela n’atteigne le SQL.
Si la falsification est une préoccupation réelle — par exemple, si un curseur contient un tenant id — signez-le avec un HMAC et vérifiez la signature en temps constant.
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");
}
Une alternative consiste à garder les curseurs entièrement côté serveur : stockez la position dans Redis sous un id aléatoire et ne transmettez au client que cet id. Cela masque complètement les colonnes de tri et permet l’expiration, au prix d’une recherche à chaque page.
Un client qui suit les curseurs
Un curseur est conçu pour être suivi, le code client consiste donc en une simple boucle : demander une page, ajouter les données, et continuer tant que nextCursor n’est pas null. Il n’y a aucun calcul de page et aucun risque de sauter une page.
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;
}
La boucle s’arrête sur nextCursor === null, c’est pourquoi ce champ doit être défini de manière fiable sur la dernière page. Pour le défilement infini (infinite scroll), le même modèle s’exécute page par page lorsqu’un élément sentinelle entre dans le viewport, et le jeton est conservé dans l’état du composant plutôt que dans l’URL.
Pagination et plan d’exécution
La pagination par clés (keyset pagination) n’est rapide que lorsque la base de données peut utiliser un index. Confirmez-le toujours avec EXPLAIN ANALYZE au lieu de le supposer.
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
Le Index Scan Backward avec un Index Cond est le résultat recherché : la base de données se rend à la position du curseur et s’arrête après vingt lignes. Un Seq Scan avec un Filter signifie que l’index ne correspond pas au tri, et que la requête scanne l’intégralité de la table à chaque page. L’index doit lister les mêmes colonnes, dans le même ordre et dans les mêmes directions que ORDER BY, avec le critère de départage (tie-breaker) à la fin.
Tester la pagination
Les propriétés qui méritent d’être testées sont la stabilité et la terminaison, et pas seulement le “happy path”. Un test qui parcourt chaque page et vérifie l’absence de doublons et de lignes manquantes permet de détecter les bugs subtils liés aux critères de départage (tie-breakers), qui resteraient autrement invisibles.
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);
});
Testez également les cas limites : la première page sans cursor, la dernière page où nextCursor est null, une page dépassant la taille maximale, et un tri qui change en cours de parcours. Ce dernier test est celui qui prouve que votre mécanisme de départage fonctionne correctement.
Bonnes pratiques
- Attribuez à chaque collection une limite par défaut et un maximum strict, et bridez la valeur plutôt que de rejeter la requête.
- Privilégiez la pagination par keyset ou par curseur pour tout élément susceptible de croître ou d’évoluer.
- Triez toujours avec un critère de départage unique tel que
id, et incluez-le dans l’index et le curseur. - Gardez les curseurs opaques, encodez-les en base64url et validez chaque champ lors du décodage.
- Récupérez
limit + 1pour déterminerhasMoreau lieu d’exécuter un count. - Retournez une enveloppe cohérente contenant
data,nextCursorethasMorepartout. - Considérez
totalcomme optionnel ; omettez-le, approximez-le ou mettez-le en cache. - Utilisez une liste blanche pour les champs et directions de tri, et liez toutes les valeurs en tant que paramètres.
- Faites en sorte que le curseur encode le contexte de tri et de filtrage afin qu’un jeton obsolète ne puisse pas être rejoué.
Erreurs courantes
- Déployer un endpoint de liste sans limite et s’en rendre compte une fois que l’application monte en charge.
- Utiliser
OFFSETpour des pages profondes et voir la latence augmenter avec le numéro de la page. - Trier sur une colonne non unique sans critère de départage, provoquant la répétition ou la disparition de lignes.
- Trier par une colonne mutable et traiter le curseur comme s’il était permanent.
- Passer un
limit, un nom de colonne ou une direction fournis par le client directement dans du SQL. - Exécuter un
count(*)non mis en cache à chaque requête de page. - Exposer des IDs internes bruts ou des timestamps dans un curseur et prétendre que c’est sécurisé.
- Retourner un simple tableau sans curseur, forçant les clients à deviner comment continuer la pagination.
- Autoriser un
limitd’un million sous prétexte que l’UI ne le demande jamais.
Et après ?
La pagination fait partie de la conception d’une API prévisible ; le guide REST est donc le complément idéal pour approfondir les formes de ressources, les codes de statut et les conventions de requête. Si vous souhaitez éviter de recalculer systématiquement la même page, le guide sur le Caching explique comment la servir depuis la mémoire, tandis que le Connection Pooling permet de limiter le coût de chaque requête paginée sur la base de données. Enfin, pour optimiser la rapidité de ces requêtes, le guide PostgreSQL détaille les index composites et les plans de requête dont dépend la pagination par clés (keyset pagination).