Guide pratique de la pagination d'API
Renvoyez toutes les lignes d'une table qui grossit dans une seule réponse et vous avez construit une fonctionnalité qui marche en développement et s'effondre en production le jour où un client possède 50 000 enregistrements. La pagination est la solution, mais l'approche naïve (LIMIT/OFFSET) contient un bug de justesse qui n'apparaît que sous écritures concurrentes, ce qui est précisément la condition de votre API en production et probablement pas celle de vos tests.
La pagination par décalage : simple, et discrètement fausse
L'approche évidente : passer un numéro de page, calculer un décalage (offset) et laisser la base sauter des lignes.
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;
app.get('/posts', async (req, res) => {
const page = Number(req.query.page ?? 1);
const pageSize = 20;
const offset = (page - 1) * pageSize;
const posts = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[pageSize, offset],
);
res.json(posts);
});
Cela fonctionne, et c'est le bon choix pour de petites tables essentiellement statiques avec une interface qui a besoin de vrais numéros de page (1, 2, 3 …). Deux vrais problèmes apparaissent dès que la table est grande et activement écrite.
Problème 1 : les performances se dégradent avec le décalage
OFFSET 100000 ne saute pas directement à la ligne 100 000 : la base doit encore parcourir et écarter les 100 000 premières lignes correspondantes avant de pouvoir renvoyer la page suivante. La page 1 est rapide ; la page 5 000 peut être nettement plus lente, avec exactement la même forme de requête.
Problème 2 : les éléments se décalent sous écritures concurrentes
C'est le bug de justesse, pas seulement de performance. Supposons qu'un client parcoure des résultats triés par created_at DESC et qu'une nouvelle ligne soit insérée entre les requêtes de la page 1 et de la page 2. Toutes les lignes descendent d'une position : le dernier élément de la page 1 est repoussé sur la page 2, et le client le voit deux fois. Si une ligne est supprimée à la place, tout remonte, et le client en saute une en silence. Aucun des deux cas n'apparaît comme une erreur ; le client reçoit simplement des données subtilement fausses.
La pagination par curseur : règle les deux
Au lieu de « saute N lignes », un curseur dit « donne-moi les lignes après celle-ci ». Le curseur est généralement la clé de tri de la dernière ligne de la page précédente, et non un nombre de lignes.
SELECT * FROM posts
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT 20;
app.get('/posts', async (req, res) => {
const pageSize = 20;
const cursor = req.query.cursor; // ISO timestamp of the last item seen, or undefined
const posts = await db.query(
cursor
? 'SELECT * FROM posts WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2'
: 'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1',
cursor ? [cursor, pageSize] : [pageSize],
);
const nextCursor = posts.length === pageSize ? posts[posts.length - 1].created_at : null;
res.json({ items: posts, nextCursor });
});
Comme la requête filtre sur created_at < $1 au lieu de sauter un nombre de lignes, une nouvelle ligne insérée en haut ne décale rien en dessous : chaque curseur suivant pointe toujours vers la même ligne réelle. Et avec un index sur created_at, cette requête est un parcours de plage rapide, quelle que soit la profondeur dans les résultats, sans coût de balayage cumulatif comme avec OFFSET.
Le curseur a besoin d'un critère de départage unique
created_at seul échoue si deux lignes partagent exactement le même horodatage : vous pourriez en sauter une ou en dupliquer une. Ajoutez la clé primaire comme critère de départage, et encodez les deux dans le curseur :
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
function encodeCursor(row) {
return Buffer.from(`${row.created_at.toISOString()}|${row.id}`).toString('base64');
}
function decodeCursor(cursor) {
const [createdAt, id] = Buffer.from(cursor, 'base64').toString().split('|');
return { createdAt, id };
}
Encoder le curseur en Base64 n'a rien à voir avec la sécurité : il s'agit de faire de la valeur un jeton opaque du point de vue du client, ce qui vous laisse libre de changer plus tard ce qu'il contient (ajouter un champ, changer de colonne de tri) sans casser les clients qui se contentent de le renvoyer tel quel.
Ce que voient réellement les clients
Une réponse paginée par curseur ressemble généralement à ceci : pas de total, pas de numéro de page, juste « voici le jeton suivant » :
{
"items": [ { "id": 512, "title": "..." }, { "id": 511, "title": "..." } ],
"nextCursor": "MjAyNi0wNy0xN1QwOTozMDowMFp8NTEx"
}
Le client conserve nextCursor et le renvoie sous la forme ?cursor=... pour la page suivante. Il est opaque pour le client : il ne doit ni l'analyser ni en construire un à la main, et ce contrat permet à votre backend de changer les détails internes de la pagination plus tard sans changement d'API incompatible.
Quand utiliser encore la pagination par décalage
La pagination par curseur renonce à la possibilité de sauter à un numéro de page arbitraire (« affiche la page 47 »), car il n'y a aucun moyen de calculer un curseur pour une page qu'on n'a pas encore récupérée. Si votre interface a réellement besoin de pages numérotées avec accès direct (un tableau d'administration avec sélecteur de page, par exemple), la pagination par décalage reste le bon outil, surtout sur des tables assez petites pour que le précipice de performance ne compte jamais en pratique. Recourez à la pagination par curseur quand la table est grande, écrite de façon concurrente, et que le client n'a besoin que de « suivant » et « précédent », ce qui décrit la plupart des flux à défilement infini et des API publiques.
À retenir
La pagination par décalage est simple et convient aux petites interfaces à pages numérotées. Dès qu'une table est grande et écrite de façon concurrente, OFFSET devient à la fois plus lent et discrètement incorrect, car les pages se décalent sous des données mouvantes. La pagination par curseur (filtrer sur « après cette ligne » au lieu de « saute N lignes », avec un critère de départage unique) règle les deux problèmes, au prix du renoncement aux sauts de page arbitraires.