Modèles de pagination pour les API modernes
Comparez la pagination offset, cursor, keyset, page tokens et Relay GraphQL avec exemples TypeScript et SQL pour les API modernes.
Pour les nouveaux endpoints de liste, privilégiez par défaut la pagination par curseur reposant sur une requête keyset — elle offre un temps de requête quasi constant quelle que soit la taille du jeu de données, et reste stable lorsque des lignes sont insérées ou supprimées entre deux récupérations de pages. Utilisez la pagination par offset uniquement lorsque les utilisateurs ont besoin d’accéder directement à un numéro de page arbitraire, que le jeu de données est petit et rarement mis à jour, ou qu’un nombre total de pages est une exigence stricte de l’interface utilisateur. Le choix repose sur deux modes de défaillance auxquels la pagination par offset ne peut échapper : un ralentissement linéaire en profondeur d’offset et un bug de fenêtre glissante qui duplique ou saute des éléments lorsque les données changent en cours de traversée. Cet article passe en revue chaque modèle de la famille — offset, curseur, keyset, tokens de page et connexions GraphQL de style Relay — avec du TypeScript exécutable et le SQL qui les sous-tend.
Points clés
- Curseur et keyset ne sont pas synonymes : un curseur est le token opaque que votre API retourne au client ; le keyset est la technique SQL — un prédicat composé
WHERE (sort_col, id) < (val, id)— qui rend la pagination par curseur performante en permettant à la base de données d’effectuer une recherche directe dans un index plutôt que de scanner et d’éliminer des lignes. - Une requête avec
OFFSET 500000 LIMIT 20ne saute pas 500 000 lignes ; PostgreSQL les lit et les élimine avant de retourner les 20 résultats, de sorte que le temps de requête croît avec la valeur de l’offset, indépendamment des index sur la colonne de tri. - Lorsque des lignes sont insérées entre deux récupérations de pages, la pagination par offset décale la fenêtre : le client reçoit un élément en double et en ignore silencieusement un autre, sans qu’aucune erreur n’apparaisse dans la réponse.
- Retourner
total_countdans chaque réponse impose unCOUNT(*)supplémentaire ; préférezhas_next_page, obtenu à moindre coût en récupérantlimit + 1lignes et en vérifiant si la ligne supplémentaire existe. - Un curseur encode une position dans un ensemble de résultats ordonné et filtré spécifique ; il doit donc être abandonné et réinitialisé à la première page dès que les paramètres de tri ou de filtrage changent.
Pourquoi la stratégie de pagination est importante : deux modes de défaillance
La stratégie de pagination est importante parce que l’approche naïve par défaut — LIMIT et OFFSET — présente deux modes de défaillance structurels qui ne se manifestent qu’à grande échelle ou sous l’effet d’écritures concurrentes. Le premier est lié aux performances : les offsets profonds obligent la base de données à lire et à éliminer des lignes. Le second concerne la cohérence : les offsets sont positionnels, de sorte que toute insertion ou suppression entre deux récupérations de pages décale la fenêtre et corrompt le jeu de résultats. Aucun de ces bugs n’apparaît sur une table petite et statique, ce qui explique pourquoi la pagination par offset semble fonctionner correctement en développement et se révèle défaillante en production.
Voici la comparaison que le reste de l’article justifie :
| Modèle | Accès aléatoire à une page | Stable sous les écritures | Performances DB en profondeur | Nombre total | Idéal pour |
|---|---|---|---|---|---|
| Offset / limit | Oui | Non | Dégradation linéaire | Abordable (requête supplémentaire) | Données statiques limitées, tables d’administration, interfaces de recherche avec numéros de page |
| Curseur (surface API) | Non | Oui | Constant avec keyset | Omettre ; utiliser has_more | Défilement infini, fils d’actualité, API de liste publiques |
| Keyset (technique DB) | Non | Oui | Constant (recherche par index) | Difficile | L’implémentation sous-jacente d’une API à curseur |
| Token de page | Non | Oui | Défini par l’implémentation | Optionnel | API souhaitant masquer et faire évoluer leur stratégie |
| Connexion Relay | Non | Oui | Constant avec keyset | Optionnel | Clients GraphQL utilisant la spécification connections |
Les deux mécanismes de droite — performances et stabilité — sont ce qui distingue les modèles. Tout ce qui suit explique pourquoi.
Pagination par offset : comment le bug de fenêtre glissante se manifeste
Discover how at OpenReplay.com.
La pagination par offset utilise page et limit (ou offset et limit) pour calculer une position numérique : offset = (page - 1) * limit. C’est le seul modèle qui permet d’accéder directement à un numéro de page arbitraire, ce qui en fait le bon choix pour les tables d’administration et les résultats de recherche où les utilisateurs cliquent directement sur « page 7 ». Ses faiblesses sont toutes deux des conséquences de cet adressage positionnel.
La faiblesse en termes de performances est un comportement documenté, et non une particularité d’implémentation. Selon la documentation PostgreSQL sur LIMIT et OFFSET, « les lignes ignorées par une clause OFFSET doivent quand même être calculées par le serveur. » Une requête avec OFFSET 500000 LIMIT 20 ne saute pas 500 000 lignes — la base de données les lit et les élimine avant de retourner les 20 résultats, ce qui signifie que le temps de requête croît avec la valeur de l’offset, indépendamment des index sur la colonne de tri. Cette sémantique est inchangée dans les versions récentes de PostgreSQL (16 à 18).
La faiblesse en termes de cohérence est le bug de fenêtre glissante. Lorsque des lignes sont insérées en tête d’un fil entre deux récupérations de pages, la pagination par offset décale la fenêtre : l’élément à la position offset sur la page N+1 est celui qui se trouvait à la position offset - 1 sur la page N, de sorte que le client reçoit un doublon — et l’élément qui est sorti de la limite est silencieusement ignoré, sans qu’aucune erreur n’apparaisse dans la réponse. Une suppression produit l’image miroir : la fenêtre se contracte et un élément est sauté.
Voici un endpoint offset correct en TypeScript avec Express et pg 8.22.x, fonctionnant sous 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);
});
Deux détails méritent d’être soulignés. La fonction clampLimit plafonne la taille de page à 100, empêchant ainsi un client de demander limit=1000000 et d’épuiser les ressources du serveur. Et récupérer limit + 1 lignes permet à l’endpoint de signaler has_next_page sans effectuer une seconde requête COUNT(*) — la même astuce qu’utilise l’implémentation par curseur ci-dessous. Si la ligne supplémentaire existe, il y a au moins une page de plus.
Pagination par curseur : la surface API
La pagination par curseur remplace l’offset numérique par un token opaque qui marque une position fixe dans le jeu de résultats. Le client renvoie ce token pour récupérer la page suivante, et comme le token s’ancre aux valeurs de tri réelles d’une ligne plutôt qu’à un comptage, les insertions et suppressions ailleurs dans la table ne corrompent pas la fenêtre. La contrepartie est la perte de l’accès aléatoire aux pages : il n’y a pas de « page 7 », seulement « la page après ce curseur ».
Curseur et keyset ne sont pas synonymes, et les confondre est l’erreur la plus fréquente dans les articles sur la pagination. Un curseur est le token opaque que votre API retourne au client, masquant la stratégie de pagination ; le keyset est la technique SQL — un prédicat composé WHERE (sort_col, id) < (val, id) — qui rend la pagination par curseur performante en permettant à la base de données d’effectuer une recherche par index plutôt que de scanner et d’éliminer des lignes. Vous pouvez implémenter une API à curseur avec une requête offset en dessous (à éviter — elle hérite du ralentissement en profondeur d’offset) ou avec une requête keyset en dessous (à privilégier). Le curseur est l’interface ; le keyset est le moteur performant.
Le token lui-même n’est qu’une position encodée — généralement la colonne de tri et un discriminant, encodés en base64 pour que les clients le traitent comme opaque et ne dépendent pas de sa structure interne. Gardez-le opaque afin de pouvoir modifier son contenu ultérieurement sans interrompre les clients en cours de traversée.
// 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);
});
L’endpoint décode le curseur en un tuple (created_at, id), exécute une requête keyset contre celui-ci, récupère limit + 1 lignes pour calculer has_more, et encode le curseur suivant à partir de la dernière ligne retournée. Pas de COUNT(*), pas d’offset, pas de scan de lignes éliminées.
Pagination keyset : le prédicat composé
La pagination keyset est la technique de base de données qui rend la pagination par curseur performante : au lieu d’un offset, elle filtre sur la clé de tri elle-même à l’aide d’un prédicat composé. PostgreSQL prend en charge les comparaisons de valeurs de lignes, de sorte que (created_at, id) < ($1, $2) compare le tuple de manière lexicographique et permet à un index composite correspondant de se positionner directement à la limite.
Le discriminant n’est pas optionnel. created_at seul n’est pas unique — deux publications peuvent partager un horodatage à la milliseconde près — et un tri sur une colonne non unique produit un ordre non déterministe, ce qui signifie qu’un curseur ancré sur created_at pourrait sauter ou répéter des lignes partageant cette valeur. L’ajout de la clé primaire (id) rend le tuple de tri unique et la limite déterministe.
-- 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;
La direction de l’index est importante. Selon la documentation PostgreSQL sur les index et ORDER BY, un index B-tree peut être parcouru dans les deux sens, mais faire correspondre l’ordre déclaré de l’index à celui du ORDER BY de la requête permet au planificateur de satisfaire à la fois le prédicat et le tri depuis un seul parcours d’index. Exécutez EXPLAIN ANALYZE sur les deux formes contre une grande table et la différence structurelle est visible dans le plan : la requête avec OFFSET 500000 montre le serveur calculant et éliminant les lignes initiales, tandis que la requête keyset affiche un parcours d’index avec un faible coût de démarrage qui ne retourne que les lignes nécessaires. La différence de coût est structurelle, et non accidentelle — elle découle directement de la sémantique documentée de OFFSET mentionnée plus haut.
Tokens de page : masquer la stratégie
Les tokens de page sont la variante à stratégie opaque de la pagination par curseur, utilisée de manière particulièrement propre par les directives de conception d’API de Google Cloud. Selon AIP-158, la requête transporte page_size et page_token, la réponse retourne un next_page_token, et la spécification exige que les tokens de page soient opaques pour le client. Encoder l’intégralité de l’état de pagination côté serveur permet au serveur de modifier sa stratégie interne — de l’offset au keyset, par exemple — sans interrompre les clients en cours de traversée.
Stripe est fréquemment cité aux côtés de Google dans ce contexte, mais les deux mécanismes ne sont pas identiques, et la distinction mérite d’être précisée. Selon la documentation de pagination de Stripe, l’API de liste de Stripe utilise des curseurs basés sur des identifiants d’objets (starting_after et ending_before, avec limit et un indicateur has_more), et non des tokens de page opaques ; c’est son API de recherche qui retourne un token next_page opaque. Le panorama précis est donc le suivant : Google Cloud utilise des tokens de page opaques, les endpoints de liste de Stripe utilisent des curseurs basés sur des identifiants d’objets, Shopify utilise des connexions basées sur des curseurs via son API GraphQL Admin, et l’API REST de GitHub pagine via l’en-tête Link — par numéro de page sur la plupart des endpoints, avec une pagination par curseur (before/after) uniquement sur certains endpoints spécifiques. Le fil conducteur est la traversée de style curseur, et non un mécanisme uniforme unique.
Connexions GraphQL de style Relay
Les connexions Relay sont la spécialisation GraphQL de la pagination par curseur, formalisée pour que chaque champ de liste dans un schéma pagine de la même manière. La spécification Relay Cursor Connections définit une connexion comme une liste d’edges, où chaque edge possède un node (l’élément) et un cursor (sa position opaque), ainsi qu’un objet pageInfo exposant hasNextPage, hasPreviousPage, startCursor et endCursor.
query {
posts(first: 10, after: "cursor123") {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Le curseur par edge est le même concept de token opaque que dans les sections REST, et pageInfo.hasNextPage est le même indicateur has_more dérivé de l’astuce consistant à récupérer une ligne supplémentaire. La structure est plus lourde qu’une enveloppe simple { data, pagination }, mais elle offre aux clients un contrat uniforme : paginer n’importe quelle connexion en passant first/after et en lisant pageInfo. En coulisses, une connexion Relay devrait toujours reposer sur une requête keyset, pour les mêmes raisons de performance.
Choisir un modèle et éviter les pièges courants
Adoptez par défaut la pagination par curseur reposant sur le keyset pour tout nouvel endpoint de liste, et ne recourez à l’offset que lorsqu’une exigence spécifique l’impose. La matrice de décision :
| Cas d’usage | Modèle |
|---|---|
| Fil à défilement infini, flux d’activité, notifications | Curseur + keyset |
| API de liste publique susceptible d’être réimplémentée ultérieurement | Token de page (opaque) |
| Champ de liste GraphQL | Connexion Relay (keyset en dessous) |
| Export en masse d’une grande table | Curseur + keyset |
| Table d’administration avec « accès direct à la page N » | Offset |
| Interface de recherche affichant des numéros de page | Offset |
| Données de référence limitées (<10 000 lignes), rarement modifiées | Offset (l’un ou l’autre convient) |
Quelques bonnes pratiques permettent de maintenir tous ces modèles corrects en production :
- Plafonnez la taille de page. Limitez
limità un maximum fixe (100 est un plafond courant) pour qu’un client ne puisse pas demander une page sans limite. - Indexez le tuple de tri complet. L’index composite doit correspondre aux deux colonnes et aux directions du
ORDER BY, faute de quoi la requête keyset se rabat sur un scan. - Préférez
has_next_pageaux comptages totaux. Retournertotal_countdans chaque réponse impose unCOUNT(*)supplémentaire, dont le coût sur une grande table dépend du gonflement de la table et de la visibility map ; dérivez plutôthas_next_pageà moindre coût depuis la lignelimit + 1, et réservez les comptages totaux aux interfaces qui en ont réellement besoin. - Gardez les curseurs opaques. Encodez-les pour que les clients ne puissent pas dépendre du format interne, vous laissant ainsi libre de modifier la stratégie sous-jacente.
- Réinitialisez le curseur lors d’un changement de filtre ou de tri. Un curseur encode une position dans un ensemble de résultats ordonné et filtré spécifique ; lorsque l’utilisateur modifie un ordre de tri ou applique un nouveau filtre, ce curseur est invalide pour la nouvelle requête et doit être abandonné — réinitialisez-le à la première page dès que les paramètres de tri ou de filtrage changent. Côté client, cela signifie lier l’état du curseur à l’état du filtre, de sorte qu’un changement de filtre efface les pages accumulées et relance la récupération depuis le début.
Les bugs de fenêtre glissante et de profondeur d’offset sont faciles à manquer lors des tests, car ils n’apparaissent que sous l’effet d’écritures concurrentes ou en profondeur, et plusieurs d’entre eux n’atteignent jamais vos journaux d’erreurs. Le bug de fenêtre glissante est invisible dans les journaux serveur — la réponse est un 200 valide avec le bon nombre d’éléments — mais dans la relecture de session, il se manifeste sous la forme d’un artefact de rendu spécifique : un élément déjà visible dans la fenêtre d’affichage s’affiche à nouveau dans le lot suivant, immédiatement adjacent à sa position précédente. Le ralentissement en profondeur d’offset se manifeste de la même manière : non pas comme un timeout ou une erreur 4xx, mais comme un indicateur de chargement « charger plus » qui ne se résout jamais, sur un 200 lent que le client attend toujours. Parce que ce sont des symptômes liés à des séquences d’interaction plutôt que des erreurs discrètes, la relecture de session est souvent le moyen de les détecter — ils résident dans la séquence complète de défilement, récupération et rendu, et non dans une seule requête échouée.
Conclusion
Adoptez la pagination par curseur reposant sur une requête keyset pour le prochain endpoint de liste que vous construisez, et traitez l’offset comme l’exception que vous justifiez plutôt que comme la valeur par défaut que vous héritez — son coût linéaire en profondeur d’offset et son instabilité due à la fenêtre glissante sont structurels, et non ajustables. Ajoutez l’index composite sur votre tuple de tri, retournez has_next_page plutôt qu’un comptage total, et gardez le curseur opaque pour pouvoir remplacer le moteur sous-jacent sans introduire de changement incompatible.
FAQ
Puis-je ajouter la pagination par curseur à une API existante basée sur l'offset sans casser les clients existants ?
Oui, mais traitez-la comme un nouveau paramètre plutôt que comme un remplacement. Acceptez un paramètre de requête cursor aux côtés des paramètres existants page et limit, et routez les requêtes portant un curseur vers la requête keyset tout en laissant les requêtes basées sur la page sur le chemin offset. Gardez le curseur opaque pour que le format interne reste libre d'évoluer, et documentez le chemin offset comme déprécié pour les endpoints à grande volumétrie ou à forte écriture, plutôt que de le supprimer immédiatement.
Que se passe-t-il pour un curseur lorsque la ligne sur laquelle il pointe est supprimée ?
Rien ne se casse, car un curseur keyset encode des valeurs de tri plutôt qu'une référence de ligne. Le prédicat composé (created_at, id) < (val, id) sélectionne toutes les lignes ordonnées après ce tuple limite, de sorte que la page suivante est retournée correctement même si la ligne d'ancrage exacte n'existe plus. C'est l'avantage fondamental par rapport à la pagination par offset, où une suppression décale toutes les positions suivantes et provoque le saut silencieux d'un élément par le client.
Pourquoi utiliser du JSON encodé en base64 pour un curseur plutôt que d'envoyer directement l'horodatage et l'ID ?
L'encodage rend le curseur opaque, ce qui signale aux clients que le token n'est pas destiné à être analysé ou construit manuellement. Si les clients lisent directement l'horodatage et l'ID bruts, ils se couplent à votre stratégie de pagination interne et se retrouvent en rupture lorsque vous la modifiez. Un token base64 opaque vous permet d'ajouter des champs, de changer les clés de tri, ou de passer des keyset aux tokens de page sans modification côté client. L'encodage n'introduit aucune surcharge significative.
La pagination par curseur fonctionne-t-elle lorsqu'on trie par une colonne autre que created_at, comme un score de popularité ?
Oui, à condition que le curseur encode le même tuple de tri que celui sur lequel la requête s'ordonne, et se termine par un discriminant unique. Trier par un score de popularité exige que le prédicat et l'index couvrent (score, id) et que le curseur transporte les deux valeurs, car les scores entrent en collision bien plus souvent que les horodatages. Sans le discriminant unique id, les lignes partageant un même score produisent un ordre non déterministe susceptible de sauter ou de répéter des éléments entre les pages.