📍 Guide du parcours : Concevoir une API REST — le guide de conception. Cet article approfondit une brique du parcours ; pour la vue d’ensemble, commencez par le guide.
Renvoyer dix mille livres sans écrouler la base
Le jour où le catalogue de la médiathèque contient dix livres, GET /livres qui renvoie tout fonctionne. Le jour où il en contient dix mille, la même requête télécharge des mégaoctets, fait transpirer la base de données et sature la connexion du client mobile qui voulait juste voir les nouveautés. La pagination n’est pas une optimisation tardive : c’est une décision de conception qu’on prend dès le premier endpoint de liste. Et tant qu’on y est, on offre au client de quoi cibler ce qu’il veut — filtrer par disponibilité, trier par date — pour ne jamais transférer plus que nécessaire.
Ce tutoriel construit un endpoint GET /livres de qualité professionnelle : paginé, filtrable et triable. Vous comparerez les deux grandes familles de pagination, comprendrez pourquoi l’une passe à l’échelle mieux que l’autre, et adopterez des conventions de filtre et de tri que vos utilisateurs devineront sans lire la doc.
🎯 Ce que vous allez apprendre
- Implémenter la pagination par décalage (offset) et comprendre ses deux faiblesses.
- Implémenter la pagination par curseur (keyset), stable et rapide sur de gros volumes.
- Exposer les liens de navigation avec l’en-tête
Linknormalisé (RFC 8288). - Concevoir des conventions de filtre et de tri claires et cohérentes.
- Réduire la taille des réponses avec les champs partiels (sparse fieldsets).
🛠️ Ce que vous allez construire
Un endpoint GET /livres?page=2&per_page=20&sort=-annee&disponible=true&fields=id,titre,annee qui pagine, trie par année décroissante, ne renvoie que les livres disponibles, et limite la réponse à trois champs. Vous produirez aussi les en-têtes de navigation qui permettent au client d’aller à la page suivante sans construire l’URL lui-même.
Prérequis
- Un serveur Express qui sert déjà
GET /livres(même celui commencé au tutoriel OpenAPI). - Des notions de base de données (la pagination se joue surtout dans la requête SQL sous-jacente).
- La distinction collection / élément, vue dans le guide du parcours.
- ⏱️ Temps estimé : environ 50 minutes.
Étape 1 — La pagination par décalage : simple et familière
La pagination par décalage est celle que tout le monde connaît, parce qu’elle ressemble aux pages d’un livre. Le client demande « la page 2, vingt résultats par page », et le serveur saute les vingt premiers pour renvoyer les vingt suivants. Deux conventions de paramètres coexistent : ?page=2&per_page=20 (orienté humain) ou ?limit=20&offset=40 (orienté base de données). Elles sont équivalentes ; choisissez-en une et tenez-vous-y.
app.get('/livres', (req, res) => {
// Valeurs par défaut + plafond : ne jamais faire confiance au client
const perPage = Math.min(parseInt(req.query.per_page) || 20, 100);
const page = Math.max(parseInt(req.query.page) || 1, 1);
const offset = (page - 1) * perPage;
// SQL équivalent : SELECT ... LIMIT $perPage OFFSET $offset
const livres = db.livres.slice(offset, offset + perPage);
const total = db.livres.length;
res.json({
donnees: livres,
pagination: { page, per_page: perPage, total }
});
});
Deux gardes sont essentielles. La valeur par défaut (20) évite qu’un client sans paramètre ne déclenche un renvoi total. Le plafond (Math.min(..., 100)) empêche un client de demander per_page=1000000 et de contourner la pagination. Sans ces deux gardes, la pagination est décorative.
✅ Point d’étape —
GET /livres?page=1&per_page=5renvoie cinq livres et un blocpaginationindiquant le total. Demandezper_page=99999: vous devez toujours en recevoir au plus 100. Si vous en recevez davantage, le plafond n’est pas appliqué.
Étape 2 — Les deux faiblesses du décalage
Le décalage a deux défauts qu’il faut connaître avant de l’adopter à grande échelle. Le premier est la performance en profondeur : pour servir OFFSET 100000, la base doit parcourir et jeter cent mille lignes avant de renvoyer les vingt voulues. Plus on va loin dans la liste, plus c’est lent — un coût invisible sur la page 2, douloureux sur la page 5000.
Le second est plus sournois : la dérive. Supposons que le client lise la page 1 (livres 1 à 20), puis qu’un bibliothécaire ajoute un livre en tête de liste, puis que le client demande la page 2. Le livre qui était à la position 20 est maintenant à la 21 et réapparaît en page 2 : le client le voit deux fois. À l’inverse, une suppression fait disparaître un élément jamais lu. Sur un catalogue qui change pendant qu’on le parcourt, le décalage n’est pas fiable.
Ces deux problèmes n’en sont pas pour une liste courte et peu mouvante. Mais pour un flux volumineux et vivant — un historique d’emprunts qui grandit en continu — ils imposent une autre approche.
Étape 3 — La pagination par curseur : stable et rapide
La pagination par curseur (aussi appelée « keyset ») résout les deux faiblesses d’un coup. Au lieu de dire « saute les 100 000 premiers », on dit « donne-moi les vingt qui viennent après tel élément ». Le « tel élément » est le curseur : une référence stable, typiquement l’identifiant ou la date du dernier élément déjà reçu.
app.get('/emprunts', (req, res) => {
const perPage = Math.min(parseInt(req.query.per_page) || 20, 100);
const apres = req.query.cursor ? Number(decodeCursor(req.query.cursor)) : 0;
// SQL : SELECT ... WHERE id > $apres ORDER BY id ASC LIMIT $perPage
const page = db.emprunts
.filter(e => e.id > apres)
.slice(0, perPage);
const dernier = page[page.length - 1];
const curseurSuivant = dernier ? encodeCursor(dernier.id) : null;
res.json({
donnees: page,
cursor_suivant: curseurSuivant // null = fin de liste
});
});
La requête SQL WHERE id > $apres s’appuie sur l’index de la clé : la base saute directement à la bonne position sans parcourir ce qui précède — rapide même très loin dans la liste. Et comme on navigue par rapport à une valeur stable et non à une position, l’ajout ou la suppression d’éléments ne provoque ni doublon ni saut. Le curseur est généralement encodé (base64) pour rester opaque : le client le traite comme un jeton à renvoyer tel quel, sans en deviner la structure.
La contrepartie est qu’on ne peut pas sauter directement à « la page 47 » : on avance de proche en proche. Pour un défilement infini (« charger plus »), c’est exactement ce qu’on veut ; pour une pagination numérotée avec accès aléatoire, le décalage reste plus adapté. Le bon choix dépend de l’usage, pas d’un dogme.
Un point de vigilance : le curseur n’est fiable que si le champ sur lequel il repose est unique et ordonné. L’identifiant fait un excellent curseur. En revanche, paginer par curseur sur un champ qui peut avoir des doublons — une date d’emprunt, par exemple — risque de sauter ou de répéter des éléments à la frontière de deux pages. La parade habituelle est de trier sur une paire de champs dont le second départage les ex æquo, typiquement (date, id) : le curseur encode alors les deux valeurs, et la condition devient « après cette date, ou même date mais identifiant supérieur ». C’est un peu plus de code, mais c’est ce qui rend la pagination par curseur réellement étanche.
✅ Point d’étape —
GET /emprunts?per_page=5renvoie cinq emprunts et uncursor_suivant. Renvoyez ce curseur dans?cursor=...: vous obtenez les cinq suivants, sans recouvrement. Quandcursor_suivantvautnull, vous êtes au bout.
Étape 4 — Exposer la navigation avec l’en-tête Link
Mettre les liens de navigation dans le corps de la réponse fonctionne, mais il existe une convention normalisée et élégante : l’en-tête HTTP Link, défini par la RFC 8288 (Web Linking) et popularisé par l’API de GitHub. Le serveur fournit les URL des pages voisines, le client n’a plus qu’à les suivre :
function construireLinkHeader(req, page, perPage, total) {
const base = `${req.protocol}://${req.get('host')}${req.path}`;
const url = (p) => `<${base}?page=${p}&per_page=${perPage}>`;
const dernierePage = Math.ceil(total / perPage);
const liens = [];
if (page < dernierePage) liens.push(`${url(page + 1)}; rel="next"`);
if (page > 1) liens.push(`${url(page - 1)}; rel="prev"`);
liens.push(`${url(1)}; rel="first"`);
liens.push(`${url(dernierePage)}; rel="last"`);
return liens.join(', ');
}
app.get('/livres', (req, res) => {
// … pagination de l'étape 1 …
res.set('Link', construireLinkHeader(req, page, perPage, total));
res.json({ donnees: livres });
});
Le client lit l’en-tête, repère le lien rel="next" et l’appelle pour la page suivante — sans jamais construire d’URL lui-même, donc sans risquer de se tromper de format. Les relations standard sont next, prev, first et last. C’est un bel exemple d’API qui guide son client : l’esprit de l’hypermédia, sans la lourdeur d’un HATEOAS complet.
✅ Point d’étape — Inspectez les en-têtes de réponse de
GET /livres?page=2&per_page=20(aveccurl -Iou l’onglet réseau du navigateur). Vous devez voir un en-têteLinkcontenantrel="next",rel="prev",rel="first"etrel="last".
Étape 5 — Filtrer : laisser le client cibler
Renvoyer moins, c’est aussi laisser le client dire ce qu’il cherche. Le filtrage n’est encadré par aucune norme : ce sont des conventions. La plus lisible expose chaque critère comme un paramètre de requête portant le nom du champ : ?disponible=true, ?auteur=7, ?annee=1979. On peut les cumuler : ?disponible=true&auteur=7 signifie « disponibles ET de l’auteur 7 ».
app.get('/livres', (req, res) => {
let resultat = db.livres;
// Chaque filtre rétrécit le résultat ; on n'applique que ceux présents
if (req.query.disponible !== undefined) {
const dispo = req.query.disponible === 'true';
resultat = resultat.filter(l => l.disponible === dispo);
}
if (req.query.auteur) {
resultat = resultat.filter(l => l.auteurId === Number(req.query.auteur));
}
if (req.query.annee) {
resultat = resultat.filter(l => l.annee === Number(req.query.annee));
}
// … puis tri, puis pagination sur `resultat` …
});
Deux règles d’or. D’abord, n’appliquez un filtre que s’il est présent : un paramètre absent ne doit jamais tout exclure. Ensuite, n’autorisez que des champs choisis (une liste blanche) : laisser filtrer sur n’importe quelle colonne ouvre la porte à des requêtes coûteuses ou à des fuites de données. Pour des besoins riches (plages, opérateurs), une convention répandue est ?annee_min=1950&annee_max=2000, plus lisible qu’une syntaxe d’opérateurs ésotérique.
Étape 6 — Trier et alléger
Le tri suit une convention simple et très répandue : un paramètre sort qui nomme le champ, préfixé d’un signe moins pour l’ordre décroissant. ?sort=titre trie par titre croissant ; ?sort=-annee par année décroissante ; ?sort=-annee,titre par année décroissante puis titre croissant. Cette grammaire tient en une ligne et se devine.
function appliquerTri(resultat, sort) {
if (!sort) return resultat;
const champs = sort.split(','); // ex. ["-annee", "titre"]
const autorises = ['titre', 'annee', 'id']; // liste blanche
return resultat.sort((a, b) => {
for (const champ of champs) {
const desc = champ.startsWith('-');
const nom = desc ? champ.slice(1) : champ;
if (!autorises.includes(nom)) continue; // on ignore l'inconnu
if (a[nom] === b[nom]) continue;
return (a[nom] > b[nom] ? 1 : -1) * (desc ? -1 : 1);
}
return 0;
});
}
Enfin, les champs partiels (sparse fieldsets) attaquent le problème par l’autre bout : au lieu de réduire le nombre de résultats, on réduit leur taille. Avec ?fields=id,titre,annee, le client demande à ne recevoir que ces trois champs au lieu de la fiche complète. Sur une liste mobile qui n’affiche qu’un titre et une couverture, c’est un gain de bande passante immédiat :
function projeter(livre, fields) {
if (!fields) return livre;
const garder = fields.split(',');
return Object.fromEntries(
Object.entries(livre).filter(([cle]) => garder.includes(cle))
);
}
✅ Point d’étape final —
GET /livres?disponible=true&sort=-annee&fields=id,titrerenvoie uniquement les livres disponibles, du plus récent au plus ancien, avec deux champs par livre. Vous avez un endpoint de liste complet : paginé, filtré, trié, projeté.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Un client télécharge tout le catalogue | Pas de valeur par défaut ni de plafond sur per_page |
Défaut (20) + plafond (100) systématiques |
| Des éléments apparaissent deux fois en paginant | Dérive du décalage sur une liste qui change | Passer à la pagination par curseur |
| La page 5000 met plusieurs secondes | OFFSET profond qui parcourt tout |
Curseur indexé (WHERE id > …) |
| Un filtre absent renvoie zéro résultat | Filtre appliqué même quand le paramètre manque | Tester la présence du paramètre avant de filtrer |
| Un client trie sur une colonne non indexée | Aucune liste blanche des champs triables | N’autoriser que des champs choisis et indexés |
🌍 Réalités du terrain
La pagination et les champs partiels prennent tout leur sens quand une part importante du trafic vient de téléphones sur réseau mobile. Chaque champ inutile dans chaque élément d’une liste de cent résultats, multiplié par des milliers d’utilisateurs, représente de la bande passante payée pour rien — par votre hébergeur en sortie, et par l’utilisateur en données mobiles. Offrir ?fields= et une taille de page raisonnable par défaut n’est pas un luxe : c’est une économie partagée.
Le calcul du total mérite aussi un mot. Compter exactement le nombre de résultats d’une grande table filtrée peut coûter plus cher que la page elle-même. Beaucoup d’API à fort volume renoncent au total exact : elles ne renvoient que « page suivante : oui/non » (via la présence d’un curseur suivant). Si l’interface n’a pas besoin d’afficher « page 3 sur 487 », s’épargner ce comptage allège chaque requête.
✅ Récapitulatif
Vous avez construit un endpoint de liste digne de la production. Vous savez paginer par décalage (simple, accès aléatoire, mais lent en profondeur et sujet à la dérive) et par curseur (stable et rapide, au prix de l’accès séquentiel), exposer la navigation via l’en-tête Link de la RFC 8288, et offrir au client des filtres, un tri et des champs partiels selon des conventions qu’il devine. Le fil conducteur : ne jamais transférer plus que ce qui est demandé, et protéger la base avec des valeurs par défaut, des plafonds et des listes blanches.
🧾 Aide-mémoire
| Paramètre | Rôle |
|---|---|
?page=2&per_page=20 |
Pagination par décalage |
?cursor=…&per_page=20 |
Pagination par curseur (keyset) |
?disponible=true&auteur=7 |
Filtres cumulés (liste blanche) |
?sort=-annee,titre |
Tri multi-champs, - = décroissant |
?fields=id,titre |
Champs partiels (alléger la réponse) |
En-tête Link |
Liens next/prev/first/last (RFC 8288) |
💪 À vous de jouer
Ajoutez un filtre par plage sur l’année : ?annee_min=1950&annee_max=2000 doit renvoyer les livres parus dans cet intervalle, bornes incluses.
Voir une solution
if (req.query.annee_min) {
resultat = resultat.filter(l => l.annee >= Number(req.query.annee_min));
}
if (req.query.annee_max) {
resultat = resultat.filter(l => l.annee <= Number(req.query.annee_max));
}
Chaque borne est indépendante : fournir seulement annee_min donne « à partir de », seulement annee_max donne « jusqu’à ». Les deux ensemble forment l’intervalle.
Tutoriels frères
- Documenter une API REST avec OpenAPI — décrire ces paramètres de requête dans le contrat.
- Sécuriser une API REST — valider et plafonner ces mêmes paramètres côté entrée.
Pour aller plus loin
- 🔝 Retour au guide : Concevoir une API REST.
- RFC 8288 — Web Linking (en-tête
Link) : rfc-editor.org/rfc/rfc8288.html. - Convention de pagination de l’API GitHub : docs.github.com.
FAQ
Décalage ou curseur, lequel choisir par défaut ? Pour une liste courte, peu changeante, où l’utilisateur veut sauter de page en page, le décalage suffit et reste plus simple. Pour un flux volumineux, vivant ou en défilement infini, le curseur est plus sûr et plus rapide. Beaucoup d’API proposent les deux selon l’endpoint.
Faut-il mettre les métadonnées de pagination dans le corps ou dans les en-têtes ? Les deux se pratiquent. Le corps (un objet pagination à côté des donnees) est plus visible et plus facile à consommer ; l’en-tête Link est plus conforme aux standards du Web et laisse le corps « pur ». L’essentiel est d’être cohérent sur toute l’API.
Comment documenter filtres et tri ? Dans le contrat OpenAPI, chaque paramètre de requête (disponible, sort, fields…) se déclare avec in: query, son schéma et une description. La documentation interactive les rend alors testables d’un clic.
Le curseur doit-il être lisible par le client ? Non. On l’encode (base64) précisément pour qu’il reste opaque : le client le renvoie tel quel sans en dépendre. Cela vous laisse libre de changer sa structure interne plus tard sans casser personne.
Mots-clés : pagination API, offset, curseur keyset, en-tête Link, RFC 8288, filtres, tri, sparse fieldsets, performance API.