REST API design - 06 - La pagination, ou comment ne pas tuer ta base

Offset, cursor, keyset : les stratégies de pagination REST, leurs compromis et leur implementation.

  1. 01 REST API design - 00 - Pourquoi le design de ton API change tout
  2. 02 REST API design - 01 - Les principes REST que personne ne lit
  3. 03 REST API design - 02 - Des URLs qui ont du sens
  4. 04 REST API design - 03 - Les méthodes HTTP, pour de vrai
  5. 05 REST API design - 04 - Les codes de statut HTTP qu'il faut connaître
  6. 06 REST API design - 05 - Body, headers et le diable dans les détails
  7. 07 REST API design - 06 - La pagination, ou comment ne pas tuer ta base
  8. 08 REST API design - 07 - Filtrage et tri sans prise de tête
  9. 09 REST API design - 08 - La validation avec Zod, gardien de ton API
  10. 10 REST API design - 09 - Erreurs : un format que tes clients vont adorer
  11. 11 REST API design - 10 - Authentification : JWT, API keys et OAuth2
  12. 12 REST API design - 11 - Versioning : quand et comment faire évoluer ton API
  13. 13 REST API design - 12 - CORS : comprendre et debugger les erreurs cross-origin
  14. 14 REST API design - 13 - Rate limiting : protéger ton API sans frustrer tes clients
  15. 15 REST API design - 14 - Caching : les bonnes réponses sont celles qu'on n'envoie pas
  16. 16 REST API design - 15 - Upload de fichiers : multipart, signed URLs et streaming
  17. 17 REST API design - 16 - Relations entre ressources : embarquer, lier ou les deux
  18. 18 REST API design - 17 - HATEOAS : des liens dans tes réponses
  19. 19 REST API design - 18 - OpenAPI : le contrat entre ton API et le monde
  20. 20 REST API design - 19 - Documentation : une API non documentee est une API inutile
  21. 21 REST API design - 20 - Testing : tester ton API sans devenir fou
  22. 22 REST API design - 21 - Webhooks : quand c'est ton API qui appelle
  23. 23 REST API design - 22 - Performance : quand chaque milliseconde compte
  24. 24 REST API design - 23 - Sécurité : les attaques que tu vas subir (et comment t'en protéger)
  25. 25 REST API design - 24 - Glossaire : tous les termes REST API en un seul endroit

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


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 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 :

  1. Ne pas le renvoyer : remplace total par hasMore. Suffisant pour la plupart des UI
  2. Le cacher : recalcule toutes les 5 minutes avec un worker
  3. Estimation : utilise EXPLAIN pour avoir une estimation rapide
  4. 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 : utilise hasMore ou 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

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.