API Design

Pagination

Chaque endpoint de collection a besoin d'une limite. La pagination transforme une requête non bornée en une requête prévisible, et le choix entre offset et curseur détermine si elle reste performante.

intermediate14 min readUpdated 16 sept. 2026
pagination.ts
ts
// pagination.ts
export type Page<T> = {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
    total?: number;
  };
};

export type Cursor = {
  createdAt: string;
  id: string;
};
Taille de page par défaut
20 éléments
Taille de page maximale
100 éléments
Style recommandé
Keyset ou curseur
Format du curseur
Opaque base64url
Départage du tri
Toujours l'id
Nombre total
Optionnel et coûteux

Pourquoi c'est important

Pourquoi chaque endpoint de liste a besoin d'une limite

Travail borné par requête

Une limite transforme une requête dont le coût croît avec la table en une requête dont le coût croît avec la page, ainsi la latence reste stable à mesure que les données s'accumulent.

Stabilité face aux écritures

Un curseur pointe vers une ligne plutôt que vers une position ; ainsi, les insertions et suppressions ailleurs dans le jeu de résultats ne peuvent pas décaler la fenêtre de lecture.

Un contrat fiable pour les clients

Une enveloppe cohérente avec data, nextCursor et hasMore permet aux clients web, mobiles et aux flux de défilement infini de partager un modèle prévisible.

Le tableau complet

Les trois concepts fondamentaux d'une page

Borner le travail, l'ordonner de manière stable et fournir au client un jeton indiquant précisément où commence la page suivante.

Bornes

Limite

Chaque requête transporte une taille de page, afin qu'aucune requête ne puisse lire, sérialiser ou retourner plus de lignes que ce que le serveur autorise.

Ordonnancement

Stabilise

Un ordre total avec un départage unique est ce qui rend un curseur significatif et empêche le saut ou la duplication de lignes.

Curseur

Recherche

Un prédicat de keyset permet à la base de données de rechercher directement la ligne suivante via un index au lieu de compter toutes les lignes précédentes.

HTML5 en un coup d'oeil

Ce qu'une page doit résoudre

limit et offset

Demande une fenêtre de lignes par position. Simple, familier, mais lent sur les pages profondes.

Curseur

Un jeton opaque qui encode la dernière ligne de la page précédente.

Prédicat de keyset

WHERE (sort_key, id) < (?, ?) recherche directement la page suivante via un index.

Tri stable

Ajoutez toujours une colonne unique telle que l'id comme dernière clé de tri.

Filtrage

Les filtres restreignent l'ensemble avant la pagination, et le curseur doit les respecter.

hasMore

Récupérez la limite plus un pour savoir si une autre page existe sans effectuer de comptage.

Modèle de données

L'enveloppe de réponse paginée

Une structure unique pour chaque endpoint de collection, que le client utilise des numéros de page, des curseurs ou un défilement infini.

L'enveloppe de réponse paginéeJSON response
  • dataarrayLa page d'enregistrements, jamais plus longue que la limite demandée
  • pagination.nextCursorstring | nullJeton opaque à renvoyer comme ?cursor= pour la page suivante ; null sur la dernière page
  • pagination.hasMorebooleanVrai lorsqu'au moins un enregistrement existe au-delà de cette page ; dérivé de la récupération limite-plus-un
  • pagination.totalnumber?Nombre total optionnel, coûteux sur les grandes tables, donc à omettre ou à servir depuis un cache

Une structure unique pour chaque endpoint de collection, que le client utilise des numéros de page, des curseurs ou un défilement infini.

Flux

Comment une page par curseur est servie

Le curseur est décodé, utilisé comme prédicat de keyset, puis remplacé par un nouveau jeton pour la page suivante.

  1. 1

    Le client envoie la limite et le curseur

    La requête contient une taille de page et, pour chaque page après la première, le curseur opaque de la réponse précédente.

  2. 2

    Le serveur décode le curseur

    Le base64url est décodé et validé. Un curseur malformé ou altéré est rejeté avant d'atteindre la base de données.

  3. 3

    Requête avec un WHERE de keyset

    Les valeurs de tri décodées deviennent une comparaison de lignes contre les colonnes de tri indexées, avec le même ORDER BY que la première page.

  4. 4

    Récupération de la limite plus un

    Demandez une ligne de plus que prévu. Sa présence prouve qu'une autre page existe sans lancer de count.

  5. 5

    Construction du curseur suivant

    Encodez la clé de tri et l'id de la dernière ligne retournée dans un nouveau jeton opaque.

  6. 6

    Retour des données et du nextCursor

    Envoyez la page dans l'enveloppe. Le curseur est null lorsqu'il n'y a plus de lignes, signalant ainsi au client qu'il doit s'arrêter.

Le guide complet

Pagination: Tout ce que vous devez savoir

Pourquoi chaque collection doit être paginée

Un endpoint de liste sans limite est une promesse que vous ne pourrez pas tenir. Le code peut être correct le jour de sa mise en production, quand la table ne contient que quelques centaines de lignes, pour ensuite devenir discrètement un risque à mesure que la table s’agrandit. Un seul SELECT * sur une table d’un million de lignes sérialisera des dizaines de mégaoctets, les gardera en mémoire et les transmettra à un client qui ne voulait probablement que les vingt premiers éléments.

La panne n’est pas progressive. À partir d’une certaine taille de table, l’endpoint franchit un seuil : la mémoire sature, l’event loop se bloque, les requêtes expirent, les tentatives de reconnexion s’accumulent, et une seule route lente fait tomber l’ensemble du service. Un attaquant non authentifié n’a même pas besoin d’un bug, une simple URL lui suffit.

La solution consiste à imposer une limite stricte sur chaque collection :

  • Une taille de page par défaut pour que les clients reçoivent une réponse utile sans avoir à s’en soucier.
  • Une taille de page maximale pour qu’aucun client ne puisse demander l’intégralité des données.
  • Un ordre stable pour éviter que les pages ne se chevauchent ou ne sautent des éléments.
  • Un jeton de continuation (continuation token) pour que le client puisse demander la tranche suivante.

Même les endpoints que vous pensez être petits méritent une limite. Les tables grossissent, des imports surviennent, et l’endpoint que vous protégez aujourd’hui est celui qui restera opérationnel demain.

La pagination par offset et ses limites

La forme la plus familière est LIMIT avec OFFSET : retourner limit lignes, en commençant après offset lignes.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

C’est facile à comprendre, cela correspond naturellement aux numéros de page (page * limit) et permet au client d’accéder directement à n’importe quelle page. Pour des tables de petite taille qui évoluent lentement, c’est parfaitement adéquat. Pour tout le reste, cela pose trois problèmes qui s’aggravent à mesure que les données augmentent.

Les pages profondes sont coûteuses. Pour retourner les lignes après un offset, la base de données doit d’abord générer et ignorer chaque ligne précédente. OFFSET 1000000 lit un million de lignes pour n’en retourner que vingt. La charge de travail augmente linéairement avec le numéro de la page : la page 1 est rapide, mais la page 50 000 ne l’est pas.

La fenêtre se déplace. L’offset est une position dans une liste qui évolue. Si une ligne est insérée avant votre position, la page suivante répète un élément ; si une ligne est supprimée, un élément est sauté. Le client voit des doublons et des manques, et aucune tentative de rafraîchissement ne peut corriger cela.

L’ordre peut être instable. Si la clé de tri présente des égalités, comme c’est souvent le cas avec created_at, la base de données est libre de retourner les lignes identiques dans n’importe quel ordre. Deux requêtes pour le même offset peuvent produire des résultats différents.

L’offset n’est pas une erreur, il est simplement inadapté aux collections volumineuses ou très dynamiques. Utilisez-le pour des tables d’administration avec des numéros de page sur des données modestes, et privilégiez un curseur lorsque la liste est susceptible de croître.

Pagination par curseur : stable par construction

Un curseur remplace le concept de « sauter N lignes » par « commencer après cette ligne ». Le client envoie un jeton opaque produit par le serveur, et le serveur le convertit à nouveau en une position précise dans le tri.

Comme le jeton désigne une ligne plutôt qu’un décompte, les insertions et suppressions ailleurs dans l’ensemble des résultats ne peuvent pas le décaler. Comme le serveur accède directement à cette ligne, la profondeur n’engendre aucun coût supplémentaire. Ces deux garanties — la stabilité et un coût constant — sont précisément celles que l’offset ne peut pas offrir.

Le prix à payer est qu’un curseur ne se déplace qu’en avant ou en arrière. La « page 50 » n’existe pas. Pour les flux, les timelines, les exports et le défilement infini, ce n’est absolument pas une perte. Pour une grille de résultats de recherche avec des pages numérotées, l’offset reste la solution naturelle tant que le volume de données reste modéré.

Un curseur doit être opaque pour les clients : encodé en base64url, traité comme une boîte noire et renvoyé tel quel. L’opacité permet au serveur de modifier les colonnes de tri ultérieurement sans casser les clients, et elle décourage la création de jetons manuels.

La pagination par clés (Keyset pagination) en SQL

La pagination par clés est ce qu’un curseur effectue au niveau de la base de données. Au lieu de compter, vous comparez les colonnes de tri aux valeurs de la dernière ligne retournée.

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

La forme (created_at, id) < ($1, $2) est une comparaison de valeurs de ligne, et elle exprime la logique clairement : prendre les lignes dont la clé de tri arrive strictement après la dernière. Postgres et la plupart des bases de données relationnelles peuvent utiliser un index correspondant à l’ordre pour pointer directement vers le point de départ.

Cet index n’est pas optionnel. La pagination par clés n’est rapide que lorsque le tri est supporté par un index sur les colonnes et les directions exactes utilisées dans ORDER BY.

CREATE INDEX posts_created_id_idx
  ON posts (created_at DESC, id DESC);

Deux détails sont à l’origine de la plupart des bugs. Premièrement, la direction de la comparaison doit correspondre au tri : un tri DESC utilise <, un tri ASC utilise >. Deuxièmement, le curseur doit inclure chaque colonne de tri. Si vous triez par created_at seul, les égalités rendent le curseur ambigu, c’est pourquoi id est toujours ajouté.

Création et encodage d’un curseur

Un curseur n’est rien d’autre que les valeurs de tri de la dernière ligne de la page, sérialisées et encodées. Gardez-le compact et validez-le lors de sa réception.

export type Cursor = { createdAt: string; id: string };

export function encodeCursor(cursor: Cursor): string {
  return Buffer.from(JSON.stringify(cursor)).toString("base64url");
}

export function decodeCursor(token: string): Cursor {
  let parsed: unknown;
  try {
    parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
  } catch {
    throw new Error("invalid_cursor");
  }

  const value = parsed as Record<string, unknown>;
  if (typeof value.createdAt !== "string" || typeof value.id !== "string") {
    throw new Error("invalid_cursor");
  }
  return { createdAt: value.createdAt, id: value.id };
}

L’astuce du “limit-plus-one” permet de savoir si une page suivante existe sans avoir à effectuer un count. Récupérez limit + 1 lignes ; si vous en obtenez plus de limit, c’est qu’il y a une page suivante, et vous supprimez la ligne supplémentaire avant de répondre.

const rows = await db.query(
  `SELECT id, title, created_at
   FROM posts
   WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
   ORDER BY created_at DESC, id DESC
   LIMIT $3`,
  [cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1],
);

const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data.at(-1);
const nextCursor = hasMore && last
  ? encodeCursor({ createdAt: last.created_at, id: last.id })
  : null;

Le Base64url est un encodage, pas une signature. Un client peut le décoder et en fabriquer un nouveau, ne considérez donc jamais un curseur comme une entrée fiable. Validez chaque champ et, si l’intégrité est critique, signez la charge utile avec un HMAC ou conservez les clés de tri côté serveur et stockez le curseur dans Redis.

L’enveloppe de réponse paginée

Renvoyez une structure cohérente pour chaque point de terminaison de collection. Les clients n’auront ainsi qu’un seul modèle à analyser, et vous pourrez faire évoluer l’implémentation interne sans modifier le contrat d’interface.

{
  "data": [
    { "id": "post_1042", "title": "Hello", "createdAt": "2026-09-16T10:00:00Z" }
  ],
  "pagination": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTE2VDEwOjAwOjAwWiIsImlkIjoicG9zdF8xMDQyIn0",
    "hasMore": true,
    "total": 1284
  }
}

data contient la page et ne dépasse jamais la limite. nextCursor est le jeton pour la page suivante, ou null lorsque la liste est épuisée, ce qui signale l’arrêt du défilement infini. hasMore est une commodité qui évite aux clients de vérifier la valeur null, et elle découle naturellement de la récupération de “limite + 1” sans coût supplémentaire.

total est optionnel et doit être traité comme tel. Une page basée sur un curseur n’en a pas besoin, et le calculer à chaque requête est souvent plus coûteux que la page elle-même. Ne l’incluez que lorsque l’UI affiche réellement “1 284 résultats”, et dans ce cas, mettez-le en cache ou utilisez une approximation.

Stabilité du tri et départage des égalités

Un curseur n’est pertinent que si le tri suit un ordre total : pour n’importe quelles deux lignes, l’une doit se trouver définitivement avant l’autre. La plupart des clés de tri naturelles ne le sont pas. De nombreux articles partagent le même created_at, et la base de données peut les retourner dans n’importe quel ordre ; ainsi, un curseur qui n’encode que le timestamp peut sauter ou répéter des lignes.

La solution consiste à ajouter une colonne unique, presque toujours la clé primaire, comme dernière clé de tri.

ORDER BY created_at DESC, id DESC

Désormais, l’ordre est déterministe et le curseur (created_at, id) est unique. La même règle s’applique à n’importe quel tri : ORDER BY score DESC, id DESC, ORDER BY name ASC, id ASC. Le critère de départage doit être unique et doit faire partie à la fois de l’index et du curseur.

Les clés de tri doivent également être stables dans le temps. Trier par updated_at et l’utiliser dans un curseur est un piège : lorsqu’une ligne est modifiée, sa position de tri change, et un curseur capturé avant la modification peut pointer au mauvais endroit. Privilégiez des clés immuables telles que created_at ou un id monotone, et si vous devez trier par un champ mutable, acceptez que les curseurs puissent devenir obsolètes.

Les comptes totaux sont coûteux

Le compte total est l’élément le plus demandé et le moins nécessaire de la pagination. SELECT count(*) avec un filtre doit examiner chaque ligne correspondante, et sur une table volumineuse, cela peut prendre plus de temps que la récupération de la page elle-même.

-- Runs on every request if you are not careful.
SELECT count(*) FROM posts WHERE author_id = $1;

Une page basée sur un curseur n’en a pas besoin. hasMore répond à la question que le client se pose réellement — « y en a-t-il d’autres ? » — sans toucher à une seule ligne supplémentaire. Si l’UI a réellement besoin d’un nombre, choisissez une approche adaptée à la précision acceptable :

  • L’omettre. La plupart des interfaces de flux ou de défilement infini n’affichent jamais de total.
  • L’estimer. Postgres expose reltuples sur pg_class et le planner peut estimer avec EXPLAIN ; les deux sont rapides et présentent une erreur de quelques pourcents.
  • Le mettre en cache. Calculez le compte selon un planning ou après des écritures, puis servez la valeur stockée.
  • Maintenir un compteur. Gardez un compte actualisé dans une table de résumé, mise à jour au sein de la même transaction que les écritures.
  • Le plafonner. Arrêtez de compter à 1 000 et retournez « 1000+ », ce qui limite le coût.

Quel que soit votre choix, n’exécutez pas de count(*) non mis en cache à chaque requête de page sur une table volumineuse.

Taille de page : valeurs par défaut et limites

Deux nombres protègent le serveur : une valeur par défaut lorsque le client ne précise rien, et un maximum strict lorsque le client en demande trop.

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

function parseLimit(raw: string | undefined): number {
  const requested = Number.parseInt(raw ?? "", 10);
  if (!Number.isFinite(requested) || requested < 1) return DEFAULT_LIMIT;
  return Math.min(requested, MAX_LIMIT);
}

Privilégiez le plafonnement (clamp) plutôt que le rejet. Un client demandant limit=1000 devrait recevoir 100 lignes et un curseur, et non une erreur 400 l’obligeant à deviner vos règles. Validez que limit est un entier positif et ne passez jamais une chaîne de caractères provenant du client directement dans du SQL.

La taille de la page est un curseur de latence. Des pages plus larges signifient moins d’allers-retours, mais plus de travail par requête et plus d’octets transférés. Pour les interfaces utilisateur interactives, une valeur entre 20 et 50 est généralement appropriée. Pour les exports de masse, utilisez un endpoint dédié avec un plafond beaucoup plus élevé et du streaming, plutôt que d’augmenter la limite sur la route interactive.

Paramètres de filtrage et de tri

La pagination se combine avec le filtrage et le tri, mais ces deux aspects doivent être gérés avec prudence car ils modifient la signification d’un curseur.

GET /posts?author_id=42&sort=-created_at&limit=20&cursor=eyJjcmVhdGVkQXQiOi...

Utilisez une liste blanche pour les champs de tri. N’interpolez jamais un nom de colonne fourni par le client directement dans du SQL. Mappez un ensemble de noms autorisés vers des expressions, et déterminez la direction du tri via un préfixe ou un paramètre explicite.

const SORTS = {
  created_at: "created_at",
  title: "title",
  score: "score",
} as const;

const column = SORTS[sortField] ?? "created_at";
const direction = order === "asc" ? "ASC" : "DESC";

Faites correspondre le curseur au tri. Si le tri change, le curseur devient insignifiant. Soit vous encodez le tri dans le curseur et rejetez toute incohérence, soit vous incluez le tri dans la charge utile (payload) du curseur et le vérifiez lors du décodage. Il en va de même pour les filtres : un curseur provenant d’une liste non filtrée ne doit pas être réutilisé pour une liste filtrée.

Ajoutez chaque colonne de tri à l’index. Un index composite sur (author_id, created_at DESC, id DESC) permet de gérer à la fois le filtre et la recherche par keyset dans une seule structure, ce qui fait la différence entre une page chargée en une milliseconde et une page chargée en une seconde.

Pagination pour la recherche et les agrégations

Les moteurs de recherche et les agrégations suivent leurs propres règles. Les backends de recherche plein texte limitent généralement from + size à environ dix mille résultats, car le décalage profond (deep offset) est également coûteux pour eux. L’équivalent du keyset dans ce contexte est un jeton search_after construit à partir des valeurs de tri du dernier résultat.

POST /posts/_search
{
  "size": 20,
  "sort": [{ "created_at": "desc" }, { "id": "desc" }],
  "search_after": ["2026-09-16T10:00:00Z", "post_1042"]
}

Il est préférable de retourner les agrégations séparément de la page. Calculer les comptes de facettes pour chaque correspondance à chaque requête est le même piège que count(*). Soit vous les calculez une seule fois et les mettez en cache, soit vous exposez un endpoint dédié que l’UI appelle lorsque l’utilisateur ouvre un panneau de filtres.

Pour les agrégats SQL, l’idée du keyset s’applique également : triez par une clé d’agrégat stable, comme un bucket de date ou un id, et utilisez cette clé dans le curseur. Ne paginez pas un GROUP BY avec OFFSET sur une table volumineuse ; matérialisez d’abord l’agrégat, puis paginez le résultat matérialisé.

Choisir une stratégie

La plupart des équipes n’ont besoin que d’une seule règle : si la collection peut devenir volumineuse ou être modifiée pendant la lecture, utilisez un cursor ; si elle est petite, évolue peu et est affichée sous forme de grille numérotée, l’offset convient parfaitement.

Small table, numbered UI          -> offset
Large or fast-changing collection -> keyset cursor
Infinite scroll or mobile feed    -> keyset cursor
Search results                    -> search_after token
Bulk export                       -> dedicated streaming endpoint

L’offset et le cursor peuvent coexister. Un tableau d’administration peut proposer des numéros de page pour la navigation et un cursor pour un flux d’« export total ». L’important est que chaque endpoint choisisse un style et le documente, plutôt que de mélanger des paramètres page et cursor d’une manière imprévisible pour les clients.

Pagination arrière

La pagination avant attire toute l’attention, mais de nombreuses interfaces ont également besoin d’un bouton “précédent”. La technique consiste à inverser la comparaison et le tri, à récupérer une page, puis à inverser l’ordre des lignes dans le code de l’application avant de les renvoyer.

async function pageBackward(prev: Cursor, limit: number) {
  const rows = await db.query(
    `SELECT id, title, created_at
     FROM posts
     WHERE (created_at, id) > ($1, $2)
     ORDER BY created_at ASC, id ASC
     LIMIT $3`,
    [prev.createdAt, prev.id, limit + 1],
  );

  // Reverse back into the canonical descending order.
  return rows.reverse();
}

Renvoyez un prevCursor construit à partir de la première ligne de la page actuelle, ainsi qu’un nextCursor construit à partir de la dernière. Un client naviguant vers l’arrière repasse en direction avant lorsque l’utilisateur recommence à défiler vers le bas ; les curseurs doivent donc être interchangeables plutôt que liés à une direction spécifique.

Garantir l’intégrité des curseurs

Le Base64url est un encodage, pas une signature. Un client peut décoder un curseur, le modifier et le renvoyer ; un curseur est donc une entrée non fiable, exactement comme un paramètre de requête. Validez chaque champ lors du décodage et rejetez tout ce qui ne correspond pas à la structure attendue avant que cela n’atteigne le SQL.

Si la falsification est une préoccupation réelle — par exemple, si un curseur contient un tenant id — signez-le avec un HMAC et vérifiez la signature en temps constant.

import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.CURSOR_SECRET!;

export function signCursor(payload: string): string {
  const mac = createHmac("sha256", secret).update(payload).digest("base64url");
  return `${Buffer.from(payload).toString("base64url")}.${mac}`;
}

export function verifyCursor(token: string): string {
  const [encoded, mac] = token.split(".");
  const expected = createHmac("sha256", secret).update(encoded).digest("base64url");
  const a = Buffer.from(mac);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error("invalid_cursor");
  }
  return Buffer.from(encoded, "base64url").toString("utf8");
}

Une alternative consiste à garder les curseurs entièrement côté serveur : stockez la position dans Redis sous un id aléatoire et ne transmettez au client que cet id. Cela masque complètement les colonnes de tri et permet l’expiration, au prix d’une recherche à chaque page.

Un client qui suit les curseurs

Un curseur est conçu pour être suivi, le code client consiste donc en une simple boucle : demander une page, ajouter les données, et continuer tant que nextCursor n’est pas null. Il n’y a aucun calcul de page et aucun risque de sauter une page.

async function fetchAll<T>(path: string): Promise<T[]> {
  const items: T[] = [];
  let cursor: string | null = null;

  do {
    const url = new URL(path, "https://api.example.com");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url);
    if (!res.ok) throw new Error(`request_failed_${res.status}`);

    const page = await res.json();
    items.push(...page.data);
    cursor = page.pagination.nextCursor;
  } while (cursor);

  return items;
}

La boucle s’arrête sur nextCursor === null, c’est pourquoi ce champ doit être défini de manière fiable sur la dernière page. Pour le défilement infini (infinite scroll), le même modèle s’exécute page par page lorsqu’un élément sentinelle entre dans le viewport, et le jeton est conservé dans l’état du composant plutôt que dans l’URL.

Pagination et plan d’exécution

La pagination par clés (keyset pagination) n’est rapide que lorsque la base de données peut utiliser un index. Confirmez-le toujours avec EXPLAIN ANALYZE au lieu de le supposer.

EXPLAIN ANALYZE
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-09-16T10:00:00Z', 'post_1042')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Limit  (cost=0.43..8.94 rows=20 width=40)
       (actual time=0.021..0.058 rows=20 loops=1)
  ->  Index Scan Backward using posts_created_id_idx on posts
        (cost=0.43..521.10 rows=417 width=40)
        (actual time=0.019..0.051 rows=20 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2026-09-16T10:00:00Z'::timestamptz, 'post_1042'))
Execution Time: 0.081 ms

Le Index Scan Backward avec un Index Cond est le résultat recherché : la base de données se rend à la position du curseur et s’arrête après vingt lignes. Un Seq Scan avec un Filter signifie que l’index ne correspond pas au tri, et que la requête scanne l’intégralité de la table à chaque page. L’index doit lister les mêmes colonnes, dans le même ordre et dans les mêmes directions que ORDER BY, avec le critère de départage (tie-breaker) à la fin.

Tester la pagination

Les propriétés qui méritent d’être testées sont la stabilité et la terminaison, et pas seulement le “happy path”. Un test qui parcourt chaque page et vérifie l’absence de doublons et de lignes manquantes permet de détecter les bugs subtils liés aux critères de départage (tie-breakers), qui resteraient autrement invisibles.

test("cursor pagination never repeats or skips rows", async () => {
  const seen = new Set<string>();
  let cursor: string | null = null;

  do {
    const page = await request(app)
      .get("/posts")
      .query({ limit: 10, cursor: cursor ?? undefined })
      .expect(200);

    for (const post of page.body.data) {
      expect(seen.has(post.id)).toBe(false);
      seen.add(post.id);
    }
    cursor = page.body.pagination.nextCursor;
  } while (cursor);

  expect(seen.size).toBe(totalPosts);
});

test("rejects a malformed cursor", async () => {
  await request(app).get("/posts?cursor=not-a-cursor").expect(400);
});

Testez également les cas limites : la première page sans cursor, la dernière page où nextCursor est null, une page dépassant la taille maximale, et un tri qui change en cours de parcours. Ce dernier test est celui qui prouve que votre mécanisme de départage fonctionne correctement.

Bonnes pratiques

  • Attribuez à chaque collection une limite par défaut et un maximum strict, et bridez la valeur plutôt que de rejeter la requête.
  • Privilégiez la pagination par keyset ou par curseur pour tout élément susceptible de croître ou d’évoluer.
  • Triez toujours avec un critère de départage unique tel que id, et incluez-le dans l’index et le curseur.
  • Gardez les curseurs opaques, encodez-les en base64url et validez chaque champ lors du décodage.
  • Récupérez limit + 1 pour déterminer hasMore au lieu d’exécuter un count.
  • Retournez une enveloppe cohérente contenant data, nextCursor et hasMore partout.
  • Considérez total comme optionnel ; omettez-le, approximez-le ou mettez-le en cache.
  • Utilisez une liste blanche pour les champs et directions de tri, et liez toutes les valeurs en tant que paramètres.
  • Faites en sorte que le curseur encode le contexte de tri et de filtrage afin qu’un jeton obsolète ne puisse pas être rejoué.

Erreurs courantes

  • Déployer un endpoint de liste sans limite et s’en rendre compte une fois que l’application monte en charge.
  • Utiliser OFFSET pour des pages profondes et voir la latence augmenter avec le numéro de la page.
  • Trier sur une colonne non unique sans critère de départage, provoquant la répétition ou la disparition de lignes.
  • Trier par une colonne mutable et traiter le curseur comme s’il était permanent.
  • Passer un limit, un nom de colonne ou une direction fournis par le client directement dans du SQL.
  • Exécuter un count(*) non mis en cache à chaque requête de page.
  • Exposer des IDs internes bruts ou des timestamps dans un curseur et prétendre que c’est sécurisé.
  • Retourner un simple tableau sans curseur, forçant les clients à deviner comment continuer la pagination.
  • Autoriser un limit d’un million sous prétexte que l’UI ne le demande jamais.

Et après ?

La pagination fait partie de la conception d’une API prévisible ; le guide REST est donc le complément idéal pour approfondir les formes de ressources, les codes de statut et les conventions de requête. Si vous souhaitez éviter de recalculer systématiquement la même page, le guide sur le Caching explique comment la servir depuis la mémoire, tandis que le Connection Pooling permet de limiter le coût de chaque requête paginée sur la base de données. Enfin, pour optimiser la rapidité de ces requêtes, le guide PostgreSQL détaille les index composites et les plans de requête dont dépend la pagination par clés (keyset pagination).

En pratique

Offset, keyset, curseur, enveloppe

Les formes de requêtes et la charge utile qu'elles produisent.

queries/offset.sql
-- Page 3 of 20. The database still walks the first 40 rows.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

-- Deep pages pay for every row they skip.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

Keyset vs OFFSET pour les pages profondes

OFFSET force la base de données à lire et rejeter chaque ligne sautée. Un prédicat de keyset recherche directement la première ligne souhaitée, donc la page un million coûte autant que la page un.

Keyset
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
OFFSET
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

-- Reads and throws away a million rows
-- before returning the twenty you asked for.

Curseur stable vs numéro de page

Un numéro de page décrit une position à travers laquelle les lignes peuvent se déplacer. Un curseur décrit la ligne où vous vous êtes arrêté, donc les lignes ajoutées ou supprimées ne peuvent pas décaler la fenêtre.

Curseur
GET /posts?limit=20&cursor=eyJjcmVhdGVkQXQiOi...
Numéro de page
GET /posts?limit=20&page=2

# A row inserted between requests shifts every
# later page, so one item is skipped and another
# is shown twice.

Compromis

Curseur ou offset ?

L'offset est plus facile à implémenter et à concevoir. Le curseur est ce qui permet à une table en croissance de rester rapide et cohérente.

Strengths

  • Le coût ne croît pas avec la profondeur

    Une requête keyset recherche via l'index, donc la millionième page coûte environ autant que la première au lieu de scanner tout ce qui précède.

  • Les résultats restent stables

    Comme le curseur désigne une ligne, les insertions et suppressions concurrentes ne peuvent pas provoquer le saut ou la répétition d'éléments, contrairement aux numéros de page.

  • Composition facile avec les filtres

    Toute clause WHERE restreint l'ensemble, et le curseur transporte simplement les valeurs de tri de la dernière ligne, donc le filtrage et la pagination s'articulent parfaitement.

Trade-offs

  • Pas d'accès aléatoire

    Les clients ne peuvent pas sauter à la page 50. Ils avancent et parfois reculent, ce qui convient aux flux mais est maladroit pour une grille de résultats numérotée.

  • Le tri est contraint

    Chaque colonne de tri doit faire partie du curseur et être stable dans le temps. Trier par un champ mutable comme updated_at brise les curseurs lorsque les lignes changent.

  • Les curseurs ne sont pas conviviaux

    Un jeton opaque ne peut pas être écrit à la main ou mis en favori de manière significative, le débogage et les liens partageables demandent donc une attention particulière.

FAQ

Foire aux questions

Keep learning

Related topics from the roadmap.

$ commencer à apprendre

Prêt à apprendre Pagination ?

Notre tutoriel interactif vous guide à travers Pagination pas à pas — avec des quiz et du vrai code que vous pouvez exécuter dans le navigateur.