Pagination par curseur pour des listes d'API stables sans bugs mystérieux
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.

Le problème : des pages de liste qui changent sous vos yeux
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 :
- Éléments dupliqués entre les pages
- Éléments manquants qui n'ont jamais été affichés
- Éléments qui sautent de position pendant le défilement
- Infinite scroll qui s'arrête trop tôt ou recharge la même page
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.
Pagination par offset en une minute
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 ».
Pourquoi l'offset casse quand des lignes sont insérées ou supprimées
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.
Ce qu'une liste stable signifie pour le web et le mobile
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 :
- Stable ordering : il existe une règle de tri claire (par exemple
created_atavec un tie-breaker commeid) de sorte que deux requêtes avec les mêmes entrées renvoient le même ordre. - Stable paging : une fois qu'un utilisateur commence à défiler vers l'avant, la page suivante continue à partir du dernier élément qu'il a vu, même si de nouveaux éléments sont ajoutés ou que certains sont supprimés.
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.
Pagination par curseur : l'idée simple
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 :
- L'API renvoie un lot d'éléments plus un curseur qui représente la position après le dernier élément.
- Le client renvoie ce curseur pour obtenir le lot suivant.
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.
Choisir le curseur et l'ordre de tri
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_atseul : simple, mais dangereux si de nombreuses lignes partagent le même horodatage.idseul : 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,idcomme tie-breaker).updated_aten 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.
Étape par étape : une forme d'API curseur propre
Vous pouvez garder la surface API petite : deux entrées, deux sorties.
1) Requête : limit + cursor
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==
2) Réponse : items + next_cursor
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.
3) Encodage du curseur : opaque préférable
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.
Gestion des insertions, suppressions et modifications
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 ?
Choisir comportement snapshot ou live
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.
Quand l'élément du curseur n'existe plus
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 :
- Pour un tri descendant :
WHERE (created_at, id) \u003c (:created_at, :id) - Incluez toujours un tie-breaker (
id) pour éviter les doublons - Si le dernier élément a été supprimé, les valeurs fonctionnent toujours
- Si le dernier élément a été édité, cela fonctionne tant que vos clés de tri sont immuables
Pagination vers l'arrière, rafraîchissement et sauts
La 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 :
- Retourner les deux directions (
next_cursorpour les éléments plus anciens etprev_cursorpour les éléments plus récents) tout en gardant un seul ordre d'affichage à l'écran. - Garder un seul curseur, mais demander un tri inversé lorsque l'utilisateur fait défiler vers le haut.
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 ».
Erreurs courantes qui créent des bugs mystérieux
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 :
- Utiliser un curseur non unique (par exemple
created_atseul) de sorte que les égalités produisent des doublons ou des éléments manquants. - Renvoyer un
next_cursorqui ne correspond pas au dernier élément réellement renvoyé. - Changer les filtres ou l'ordre de tri entre les requêtes de page.
- Mélanger offset et curseur dans le même endpoint.
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.
Checklist rapide avant de livrer
Avant de déclarer la pagination « terminée », vérifiez le comportement sous insertions, suppressions et retries.
- Le tri est explicite, déterministe et inclut un tie-breaker
- Chaque requête répète exactement les mêmes filtres et champs de tri
next_cursorest pris depuis la dernière ligne renvoyéelimita un max sûr et un défaut documenté- Le comportement de rafraîchissement est défini (comment les nouveaux éléments apparaissent)
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.
Un exemple réaliste : une boîte de réception stable entre appareils
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 :
- Commencez à récupérer des pages pendant qu'un script insère de nouveaux messages chaque seconde
- Supprimez quelques messages en plein défilement
- Éditez un message (sans changer ses champs de tri)
- Confirmez que vous n'obtenez jamais de répétitions, de trous ou d'éléments hors ordre
- Vérifiez que l'appli mobile et l'appli web montrent des frontières de page cohérentes
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.
FAQ
Why do I get duplicates or missing items when I paginate with offset?
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.
How does cursor pagination prevent the “list changed under me” problem?
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.
What should I use as a cursor: created_at, id, or both?
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.
Can I paginate by updated_at for a live feed?
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.
What should my API response include for cursor pagination?
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.
What happens if the item referenced by the cursor gets deleted?
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.
How do I avoid repeats when fetching the next page?
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.
How should refresh work with cursor pagination?
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.
Can I reuse the same cursor if the user changes filters or sort order?
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.
How do I support jumping to a specific page or random access with cursors?
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.