06 - La pagination, ou comment ne pas tuer ta base
Ce que tu vas apprendre
- Les trois stratégies de pagination : offset, cursor et keyset
- Leurs compromis en termes de performance et d'UX
- Comment renvoyer les metadonnees de pagination
- Pourquoi
SELECT COUNT(*)peut devenir ton pire ennemi
Prerequisites
- Avoir lu l'article sur le body et les headers
- Connaitre les bases de SQL (SELECT, LIMIT, OFFSET)
Le jour ou la page 3000 a fait tomber la prod
Un collegue m'appelle un vendredi soir. "L'API est lente, les requêtes mettent 30 secondes." Je regarde les logs. Un bot parcourait la liste des produits page par page. Page 1, page 2... page 3000. Avec OFFSET 60000, LIMIT 20, PostgreSQL devait scanner 60 000 lignes pour en renvoyer 20. Multiplie par les requêtes concurrentes du bot, et la base etait a genoux.
La pagination a l'air simple. Mais mal faite, elle peut devenir le goulot d'etranglement de toute ton API.
Pagination par offset
La plus intuitive. Le client demande une page et une taille.
httpGET /api/products?page=3&limit=20
typescriptrouter.get("/api/products", async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(Number(req.query.limit) || 20, 100);
const offset = (page - 1) * limit;
const [products, total] = await Promise.all([
db.query("SELECT * FROM products ORDER BY id LIMIT $1 OFFSET $2", [
limit,
offset,
]),
db.query("SELECT COUNT(*) FROM products"),
]);
res.json({
data: products.rows,
meta: {
total: Number(total.rows[0].count),
page,
limit,
totalPages: Math.ceil(Number(total.rows[0].count) / limit),
},
});
});
Avantages : simple a comprendre, le client peut sauter a n'importe quelle page.
Problèmes :
- Performance qui se degrade avec l'offset (O(n) en base)
- Résultats instables : si un élément est inséré pendant la navigation, tu peux voir des doublons ou rater des éléments
Pagination par cursor
Le client recoit un curseur opaque qui pointe vers le dernier élément vu.
httpGET /api/products?limit=20
GET /api/products?cursor=eyJpZCI6MTAwfQ&limit=20
typescriptrouter.get("/api/products", async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const cursor = req.query.cursor
? JSON.parse(Buffer.from(String(req.query.cursor), "base64url").toString())
: null;
const whereClause = cursor ? "WHERE id > $2" : "";
const params = cursor ? [limit + 1, cursor.id] : [limit + 1];
const products = await db.query(
`SELECT * FROM products ${whereClause} ORDER BY id LIMIT $1`,
params
);
const hasMore = products.rows.length > limit;
const data = hasMore ? products.rows.slice(0, -1) : products.rows;
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString(
"base64url"
)
: null;
res.json({
data,
meta: {
hasMore,
nextCursor,
},
});
});
Avantages : performance constante O(1) quel que soit la position, résultats stables.
Problèmes : pas de saut a une page arbitraire, pas de total facilement accessible.
Pagination par keyset
Variante du cursor, mais avec des valeurs explicites au lieu d'un curseur opaque.
httpGET /api/products?limit=20&after_id=100
GET /api/products?limit=20&created_after=2026-03-28T00:00:00Z
typescriptrouter.get("/api/products", async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const afterId = req.query.after_id ? Number(req.query.after_id) : null;
const query = afterId
? "SELECT * FROM products WHERE id > $2 ORDER BY id LIMIT $1"
: "SELECT * FROM products ORDER BY id LIMIT $1";
const params = afterId ? [limit + 1, afterId] : [limit + 1];
const products = await db.query(query, params);
const hasMore = products.rows.length > limit;
const data = hasMore ? products.rows.slice(0, -1) : products.rows;
res.json({
data,
meta: {
hasMore,
nextAfterId: hasMore ? data[data.length - 1].id : null,
},
});
});
Memes avantages que le cursor, avec en plus des paramètres lisibles et debuggables.
Le header Link
Le standard HTTP pour la navigation de pagination :
httpHTTP/1.1 200 OK
Link: </api/products?page=3&limit=20>; rel="next",
</api/products?page=1&limit=20>; rel="prev",
</api/products?page=1&limit=20>; rel="first",
</api/products?page=150&limit=20>; rel="last"
GitHub utilise ce pattern pour toutes ses API. C'est elegant et respecte la spec HTTP, mais peu de clients le parsent automatiquement. Je recommande d'inclure les liens de navigation dans le body ET dans le header Link.
Le piège du COUNT(*)
sql-- Cette requete est rapide sur 1000 lignes
-- et atroce sur 10 millions
SELECT COUNT(*) FROM products WHERE status = 'active';
PostgreSQL doit parcourir toutes les lignes matchant le filtre pour compter. Pas d'index magique pour ca (contrairement a MySQL/InnoDB qui a des optimisations spécifiques).
Mes stratégies pour gerer le total :
- Ne pas le renvoyer : remplace
totalparhasMore. Suffisant pour la plupart des UI - Le cacher : recalcule toutes les 5 minutes avec un worker
- Estimation : utilise
EXPLAINpour avoir une estimation rapide - Le limiter : "plus de 10 000 résultats" au lieu du nombre exact
typescript// Approche pragmatique : cap a 10000
const countQuery = await db.query(
"SELECT COUNT(*) FROM (SELECT 1 FROM products WHERE status = $1 LIMIT 10001) t",
[status]
);
const total = Number(countQuery.rows[0].count);
const isExact = total <= 10000;
res.json({
data: products,
meta: {
total: isExact ? total : 10000,
totalIsExact: isExact,
hasMore,
},
});
Quel pattern choisir ?
| Critère | Offset | Cursor | Keyset |
|---|---|---|---|
| Saut a une page | oui | non | non |
| Performance | se degrade | constante | constante |
| Stabilite | non | oui | oui |
| Simplicite | haute | moyenne | moyenne |
| Tri complexe | facile | possible | plus dur |
Mon conseil : commence avec l'offset pour les petites collections (<10 000 éléments). Passe au cursor/keyset des que la table grossit ou que tu as des insertions frequentes.
Tu peux retrouver des benchmarks de pagination sur paltemps.fr.
Résumé
- L'offset est simple mais se degrade en performance sur les grandes tables
- Le cursor et le keyset offrent des performances constantes mais pas de saut de page
COUNT(*)est un piège de performance : utilisehasMoreou des estimations- Inclus les liens de navigation dans le body et dans le header Link
- Choisis ta stratégie en fonction de la taille de tes donnees et des besoins de l'UI
Precedent : Body et headers | Suivant : Filtrage et tri
Sources
- Use The Index, Luke -- Pagination Done the Right Way. https://use-the-index-luke.com/no-offset
- Slack Engineering -- Evolving API Pagination at Slack. https://slack.engineering/evolving-api-pagination-at-slack/
- RFC 8288 -- Web Linking. https://www.rfc-editor.org/rfc/rfc8288