La pagination par curseur rend les listes stables lorsque les données changent. Comprenez pourquoi la pagination par offset casse avec les insertions et suppressions, et comment implémenter des curseurs propres.

Vous ouvrez un fil, vous faites défiler un peu, et tout semble normal... jusqu'à ce que ça ne le soit plus. Vous voyez le même élément deux fois. Quelque chose que vous juriez avoir vu a disparu. Une ligne que vous étiez sur le point d'appuyer se décale et vous aboutissez sur la mauvaise page de détail.
Ce sont des bugs visibles par l'utilisateur, même si vos réponses d'API semblent « correctes » isolément. Les symptômes habituels sont faciles à repérer :
C'est pire sur mobile. Les gens mettent en pause, basculent d'application, perdent la connectivité, puis reprennent plus tard. Pendant ce temps, de nouveaux éléments arrivent, d'autres sont supprimés, et certains sont édités. Si votre appli continue de demander la « page 3 » en utilisant un offset, les frontières de page peuvent bouger pendant que l'utilisateur est en train de défiler. Le résultat est un fil qui semble instable et peu fiable.
L'objectif est simple : une fois qu'un utilisateur commence à défiler vers l'avant, la liste doit se comporter comme une capture instantanée. De nouveaux éléments peuvent exister, mais ils ne doivent pas réorganiser ce que l'utilisateur est en train de parcourir. L'utilisateur doit obtenir une séquence fluide et prévisible.
Aucune méthode de pagination n'est parfaite. Les systèmes réels ont des écritures concurrentes, des modifications et plusieurs options de tri. Mais la pagination par curseur est généralement plus sûre que l'offset parce qu'elle avance à partir d'une position spécifique dans un ordre stable, au lieu de partir d'un nombre de lignes mouvant.
La pagination par offset est la méthode « skip N, take M » pour paginer une liste. Vous dites à l'API combien d'éléments sauter (offset) et combien retourner (limit). Avec limit=20, vous obtenez 20 éléments par page.
Conceptuellement :
GET /items?limit=20\u0026offset=0 (première page)GET /items?limit=20\u0026offset=20 (deuxième page)GET /items?limit=20\u0026offset=40 (troisième page)La réponse inclut généralement les éléments plus assez d'info pour demander la page suivante.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Elle est populaire parce qu'elle se mappe bien aux tables, aux listes d'admin, aux résultats de recherche et aux fils simples. Elle est aussi facile à implémenter en SQL avec LIMIT et OFFSET.
Le piège est l'hypothèse cachée : le jeu de données reste immobile pendant que l'utilisateur pagine. Dans les applis réelles, de nouvelles lignes sont insérées, des lignes sont supprimées, et des clés de tri changent. C'est là que commencent les « bugs mystérieux ».
La pagination par offset suppose que la liste reste fixe entre les requêtes. Mais les listes réelles bougent. Quand la liste se décale, un offset comme « skip 20 » ne pointe plus vers les mêmes éléments.
Imaginez un fil trié par created_at desc (du plus récent au plus ancien), taille de page 3.
Vous chargez la page 1 avec offset=0, limit=3 et obtenez [A, B, C].
Maintenant un nouvel élément X est créé et apparaît en tête. La liste devient [X, A, B, C, D, E, F, ...]. Vous chargez la page 2 avec offset=3, limit=3. Le serveur saute [X, A, B] et renvoie [C, D, E].
Vous venez de voir C deux fois (un doublon), et plus tard vous manquerez un élément parce que tout s'est décalé vers le bas.
Les suppressions provoquent l'échec inverse. Commencez par [A, B, C, D, E, F, ...]. Vous chargez la page 1 et voyez [A, B, C]. Avant la page 2, B est supprimé, donc la liste devient [A, C, D, E, F, ...]. La page 2 avec offset=3 saute [A, C, D] et renvoie [E, F, G]. D devient un trou que vous ne récupérez jamais.
Dans les fils « newest-first », les insertions se produisent en haut, ce qui décale exactement tous les offsets suivants.
Une « liste stable » est ce à quoi les utilisateurs s'attendent : au fur et à mesure qu'ils font défiler vers l'avant, les éléments ne sautent pas, ne se répètent pas et ne disparaissent pas sans raison. Il s'agit moins de figer le temps que de rendre le paging prévisible.
Deux idées sont souvent confondues :
created_at avec un tie-breaker comme id) de sorte que deux requêtes avec les mêmes entrées renvoient le même ordre.Rafraîchir et défiler vers l'avant sont des actions différentes. Rafraîchir signifie « montre-moi ce qui est nouveau maintenant », donc le haut peut changer. Défilement vers l'avant signifie « continue d'où j'en étais », donc vous ne devriez pas voir de répétitions ni de trous inattendus causés par le déplacement des frontières de page.
Une règle simple qui empêche la plupart des bugs de pagination : le défilement vers l'avant ne doit jamais afficher de répétitions.
La pagination par curseur parcourt une liste en utilisant un marque-page au lieu d'un numéro de page. Plutôt que « donne-moi la page 3 », le client dit « continue d'ici ».
Le contrat est simple :
Cela tolère mieux les insertions et suppressions parce que le curseur ancre une position dans l'ordre trié, pas un comptage de lignes mouvant.
L'exigence non négociable est un ordre de tri déterministe. Vous avez besoin d'une règle d'ordre stable et d'un tie-breaker cohérent, sinon le curseur n'est pas un marque-page fiable.
Commencez par choisir un ordre de tri qui correspond à la façon dont les gens lisent la liste. Les fils, messages et journaux d'activité sont généralement du plus récent au plus ancien. Les historiques comme les factures et les logs d'audit sont souvent plus simples du plus ancien au plus récent.
Un curseur doit identifier de façon unique une position dans cet ordre. Si deux éléments peuvent partager la même valeur de curseur, vous finirez par avoir des doublons ou des trous.
Choix courants et points d'attention :
created_at seul : simple, mais dangereux si de nombreuses lignes partagent le même horodatage.id seul : sûr si les IDs sont monotones, mais peut ne pas correspondre à l'ordre produit souhaité.created_at + id : généralement le meilleur compromis (timestamp pour l'ordre produit, id comme tie-breaker).updated_at en primaire : risqué pour l'infinite scroll car les éditions peuvent déplacer les éléments entre les pages.Si vous proposez plusieurs options de tri, traitez chaque mode de tri comme une liste différente avec ses propres règles de curseur. Un curseur n'a de sens que pour un ordre exact.
Vous pouvez garder la surface API petite : deux entrées, deux sorties.
Envoyez un limit (combien d'éléments vous voulez) et un cursor optionnel (où continuer). Si le curseur est absent, le serveur renvoie la première page.
Exemple de requête :
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Renvoyez les éléments et un next_cursor. S'il n'y a plus de page suivante, renvoyez next_cursor: null. Les clients doivent traiter le curseur comme un token, pas comme quelque chose à modifier.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Logique côté serveur en mots simples : triez dans un ordre stable, filtrez en utilisant le curseur, puis appliquez la limite.
Si vous triez du plus récent au plus ancien par (created_at DESC, id DESC), décodez le curseur en (created_at, id), puis récupérez les lignes où (created_at, id) est strictement inférieur à la paire du curseur, appliquez le même ordre et prenez limit lignes.
Vous pouvez encoder le curseur comme un blob JSON base64 (facile) ou comme un token signé/chiffré (plus de travail). Opaque est plus sûr car il vous permet de changer l'interne plus tard sans casser les clients.
Définissez aussi des valeurs par défaut sensées : un défaut mobile raisonnable (souvent 20-30), un défaut web (souvent 50) et un maximum côté serveur pour qu'un client bogué ne puisse pas demander 10 000 lignes.
Un fil stable concerne principalement une promesse : une fois que l'utilisateur commence à défiler vers l'avant, les éléments qu'il n'a pas encore vus ne doivent pas se mettre à bouger parce que quelqu'un d'autre a créé, supprimé ou modifié des enregistrements.
Avec la pagination par curseur, les insertions sont les plus simples. Les nouveaux enregistrements doivent apparaître au rafraîchissement, pas au milieu des pages déjà chargées. Si vous triez par created_at DESC, id DESC, les nouveaux éléments vivent naturellement avant la première page, donc votre curseur existant continue vers des éléments plus anciens.
Les suppressions ne devraient pas réarranger la liste. Si un élément est supprimé, il ne sera simplement pas renvoyé quand vous auriez dû le récupérer. Si vous avez besoin de garder la taille des pages constante, continuez à récupérer jusqu'à rassembler limit éléments visibles.
Les modifications sont le point où les équipes réintroduisent accidentellement des bugs. La question clé : une modification peut-elle changer la position de tri ?
Le comportement de type snapshot est généralement le meilleur pour les listes à défiler : pagez selon une clé immuable comme created_at. Les éditions peuvent changer le contenu, mais l'élément ne saute pas de position.
Le comportement live trie par quelque chose comme edited_at. Cela peut provoquer des sauts (un vieil élément est édité et remonte près du haut). Si vous choisissez cela, traitez la liste comme en changement permanent et concevez l'UX autour du rafraîchissement.
Ne faites pas dépendre le curseur de « trouver cette ligne exacte ». Encodez la position, par exemple {created_at, id} du dernier élément renvoyé. Ensuite la requête suivante se base sur des valeurs, pas sur l'existence de la ligne :
WHERE (created_at, id) \u003c (:created_at, :id)id) pour éviter les doublonsLa pagination vers l'avant est la partie facile. Les questions UX plus délicates sont la pagination vers l'arrière, le rafraîchissement et l'accès aléatoire.
Pour la pagination vers l'arrière, deux approches fonctionnent :
next_cursor pour les éléments plus anciens et prev_cursor pour les éléments plus récents) tout en gardant un seul ordre d'affichage à l'écran.Les sauts aléatoires sont plus difficiles avec les curseurs parce que « page 20 » n'a pas de sens stable quand la liste change. Si vous avez vraiment besoin de sauts, sautez vers une ancre comme « autour de cet horodatage » ou « à partir de cet id », pas vers un index de page.
Sur mobile, le cache compte. Stockez les curseurs par état de liste (requête + filtres + tri), et traitez chaque onglet/vue comme sa propre liste. Cela évite le comportement « changer d'onglet et tout se mélange ».
La plupart des problèmes de pagination par curseur ne viennent pas de la base de données. Ils viennent de petites incohérences entre les requêtes qui n'apparaissent qu'en trafic réel.
Les plus grands coupables :
created_at seul) de sorte que les égalités produisent des doublons ou des éléments manquants.next_cursor qui ne correspond pas au dernier élément réellement renvoyé.Si vous construisez des applis sur des plateformes comme Koder.ai, ces cas limites apparaissent vite parce que les clients web et mobile partagent souvent le même endpoint. Avoir un contrat de curseur explicite et une règle d'ordre déterministe garde les deux clients cohérents.
Avant de déclarer la pagination « terminée », vérifiez le comportement sous insertions, suppressions et retries.
next_cursor est pris depuis la dernière ligne renvoyéelimit a un max sûr et un défaut documentéPour le rafraîchissement, choisissez une règle claire : soit les utilisateurs tirent pour rafraîchir afin de récupérer les éléments plus récents en haut, soit vous vérifiez périodiquement « quelque chose de plus récent que mon premier élément ? » et affichez un bouton « Nouveaux éléments ». La cohérence est ce qui rend la liste stable plutôt que hantée.
Imaginez une boîte de support que les agents utilisent sur le web, tandis qu'un manager la consulte sur mobile. La liste est triée du plus récent au plus ancien. Les gens attendent une chose : quand ils défilent vers l'avant, les éléments ne sautent pas, ne se répètent pas et ne disparaissent pas.
Avec la pagination par offset, un agent charge la page 1 (éléments 1-20), puis va à la page 2 (offset=20). Pendant qu'il lit, deux nouveaux messages arrivent en tête. Maintenant offset=20 pointe vers un endroit différent qu'il y a une seconde. L'utilisateur voit des doublons ou manque des messages.
Avec la pagination par curseur, l'appli demande « les 20 éléments suivants après ce curseur », où le curseur est basé sur le dernier élément que l'utilisateur a réellement vu (souvent (created_at, id)). Les nouveaux messages peuvent arriver toute la journée, mais la page suivante commence toujours juste après le dernier message que l'utilisateur a vu.
Une façon simple de tester avant de livrer :
Si vous prototypez rapidement, Koder.ai peut vous aider à structurer l'endpoint et les flux clients depuis un prompt de chat, puis itérer en toute sécurité en utilisant le Planning Mode plus des snapshots et rollback quand un changement de pagination vous surprend pendant les tests.
La pagination par offset indique de « sauter N lignes », donc quand de nouvelles lignes sont insérées ou que des lignes existantes sont supprimées, le comptage des lignes change. Le même offset peut soudainement référer à des éléments différents qu'un instant plus tôt, ce qui crée des doublons et des trous pour les utilisateurs en pleine lecture.
La pagination par curseur utilise un signet qui représente « la position après le dernier élément que j'ai vu ». La requête suivante continue depuis cette position dans un ordre déterministe, donc les insertions en haut et les suppressions au milieu ne déplacent pas la frontière de page comme le font les offsets.
Utilisez un tri déterministe avec un tie-breaker, le plus courant étant (created_at, id) dans la même direction. created_at donne l'ordre attendu par le produit, et id rend chaque position unique pour éviter de répéter ou de sauter des éléments lorsque des horodatages se chevauchent.
Trier par updated_at peut faire sauter des éléments entre les pages lorsqu'ils sont édités, ce qui casse l'attente d'un défilement stable vers l'avant. Si vous avez besoin d'une vue « récemment modifiée » en direct, concevez l'interface pour accepter le réordonnancement (rafraîchissement) plutôt que de promettre un infinite scroll stable.
Retournez un token opaque sous la clé next_cursor et demandez au client de le renvoyer tel quel. Une approche simple est d'encoder le (created_at, id) du dernier élément en JSON base64, mais traiter le curseur comme opaque est l'essentiel afin de pouvoir changer l'implémentation interne plus tard.
Construisez la requête suivante à partir des valeurs du curseur, pas en « trouver cette ligne exacte ». Si le dernier élément a été supprimé, le (created_at, id) stocké définit toujours une position et permet de continuer en toute sécurité avec un filtre « strictement moins que » (ou « greater than ») dans le même ordre.
Utilisez une comparaison stricte et un tie-breaker unique, et prenez toujours le curseur depuis le dernier élément que vous avez réellement renvoyé. La plupart des bugs de répétition viennent d'utiliser \u003c= au lieu de \u003c, d'omettre le tie-breaker ou de générer next_cursor à partir de la mauvaise ligne.
Choisissez une règle claire : le rafraîchissement charge les éléments plus récents en haut, tandis que le défilement vers l'avant continue vers les éléments plus anciens à partir du curseur existant. Ne mélangez pas les « sémantiques de rafraîchissement » dans le même flux de curseur, ou les utilisateurs verront un réordonnancement et jugeront la liste peu fiable.
Un curseur n'est valide que pour un ordre exact et un ensemble de filtres donnés. Si le client change le mode de tri, la requête de recherche ou les filtres, il doit démarrer une nouvelle session de pagination sans curseur et stocker les curseurs séparément par état de liste.
La pagination par curseur est excellente pour la navigation séquentielle mais moins adaptée pour un saut stable vers « la page 20 » car le jeu de données peut changer. Si vous devez sauter, sauter vers une ancre comme « autour de cet horodatage » ou « à partir de cet id », puis paginer avec des curseurs depuis cet endroit.