03 oct. 2025·8 min de lecture

Pagination par curseur vs offset pour des API rapides d'écrans d'administration

Comprenez la pagination par curseur vs offset et adoptez un contrat d'API cohérent pour le tri, les filtres et les totaux afin de garder les écrans d'administration rapides sur web et mobile.

Pagination par curseur vs offset pour des API rapides d'écrans d'administration

Pourquoi la pagination peut rendre les écrans d'administration lents

Les écrans d'administration commencent souvent par un tableau simple : charger les 25 premières lignes, ajouter une boîte de recherche, et c'est tout. Ça paraît instantané avec quelques centaines d'enregistrements. Puis le jeu de données grossit, et le même écran commence à ramer.

Le problème habituel n'est pas l'interface. C'est ce que l'API doit faire avant de pouvoir renvoyer la page 12 avec tri et filtres appliqués. À mesure que la table s'agrandit, le backend passe plus de temps à trouver les correspondances, les compter et sauter les résultats précédents. Si chaque clic déclenche une requête plus lourde, l'écran donne l'impression de réfléchir plutôt que de répondre.

On le remarque souvent aux mêmes endroits : les changements de page deviennent plus lents avec le temps, le tri devient peu réactif, la recherche paraît incohérente d'une page à l'autre, et le défilement infini charge par à-coups (rapide, puis soudainement lent). Dans les systèmes très actifs, on peut même voir des doublons ou des lignes manquantes quand les données changent entre les requêtes.

Les interfaces web et mobiles poussent aussi la pagination dans des directions différentes. Un tableau d'administration web encourage les sauts vers une page précise et le tri par plusieurs colonnes. Les écrans mobiles utilisent généralement une liste infinie qui charge le morceau suivant, et les utilisateurs attendent que chaque chargement soit aussi rapide que le précédent. Si votre API est conçue uniquement autour des numéros de page, le mobile en souffrira souvent. Si elle est conçue uniquement autour du next/after, les tableaux web peuvent paraître limités.

L'objectif n'est pas seulement de renvoyer 25 éléments. C'est d'avoir un paging rapide et prévisible qui reste stable à mesure que les données croissent, avec des règles valables aussi bien pour les tableaux que pour les listes infinies.

Principes de pagination dont dépend votre UI

La pagination consiste à diviser une longue liste en morceaux plus petits afin que l'écran puisse charger et afficher rapidement. Plutôt que de demander à l'API tous les enregistrements, l'UI demande la tranche suivante de résultats.

Le contrôle le plus important est la taille de page (souvent appelée limit). Des pages plus petites paraissent généralement plus rapides parce que le serveur fait moins de travail et l'application affiche moins de lignes. Mais des pages trop petites peuvent sembler saccadées car l'utilisateur doit cliquer ou défiler plus souvent. Pour beaucoup de tableaux d'administration, 25 à 100 éléments est une plage pratique, le mobile préférant en général le bas de cette fourchette.

Un ordre de tri stable compte plus que ce que la plupart des équipes imaginent. Si l'ordre peut changer entre les requêtes, les utilisateurs voient des doublons ou des lignes manquantes lors de la pagination. Un tri stable signifie généralement trier par un champ principal (comme created_at) plus un bris d'égalité (comme id). Cela importe que vous utilisiez la pagination par offset ou par curseur.

Du point de vue du client, une réponse paginée doit inclure les éléments, un indice pour la page suivante (numéro de page ou jeton curseur), et seulement les totaux dont l'UI a vraiment besoin. Certains écrans ont besoin d'un total exact pour « 1–50 sur 12 340 ». D'autres n'ont besoin que de has_more.

Pagination par offset : comment ça marche et où ça fait mal

La pagination par offset est l'approche classique page N. Le client demande un nombre fixe de lignes et indique à l'API combien de lignes sauter au départ. Vous la verrez sous la forme limit et offset, ou page et pageSize que le serveur convertit en offset.

Une requête typique ressemble à ceci :

  • GET /tickets?limit=50\u0026offset=950
  • “Donne-moi 50 tickets, en sautant les 950 premiers.”

Elle correspond aux besoins courants d'administration : sauter à la page 20, consulter des enregistrements anciens, ou exporter une grosse liste par morceaux. C'est aussi facile à expliquer en interne : « Regarde la page 3 et tu la verras. »

Le problème apparaît sur les pages profondes. Beaucoup de bases de données doivent encore passer par les lignes sautées avant de retourner votre page, surtout quand l'ordre de tri n'est pas soutenu par un index serré. La page 1 peut être rapide, mais la page 200 peut devenir sensiblement plus lente, ce qui rend justement les écrans d'administration lents quand les utilisateurs défilent ou sautent de page.

L'autre problème est la cohérence quand les données changent. Imaginez un responsable support qui ouvre la page 5 des tickets triés par les plus récents en premier. Pendant qu'il regarde, de nouveaux tickets arrivent ou des tickets plus anciens sont supprimés. Les insertions peuvent faire avancer des éléments (doublons entre pages). Les suppressions peuvent faire reculer des éléments (des enregistrements disparaissent du parcours de l'utilisateur).

La pagination par offset peut rester acceptable pour de petites tables, des jeux de données stables ou des exports ponctuels. Sur des tables larges et actives, les cas limites apparaissent vite.

Pagination par curseur : comment ça marche et pourquoi ça reste stable

La pagination par curseur utilise un curseur comme signet. Plutôt que de dire “donne-moi la page 7”, le client dit “continue après cet élément précis”. Le curseur encode généralement les valeurs de tri du dernier élément (par exemple created_at et id) pour que le serveur reprenne au bon endroit.

La requête est généralement juste :

  • limit : combien d'éléments retourner
  • cursor : un jeton opaque provenant de la réponse précédente (souvent appelé after)

La réponse renvoie des éléments plus un nouveau curseur qui pointe vers la fin de cette tranche. La différence pratique est que les curseurs ne demandent pas à la base de données de compter et de sauter des lignes. Ils lui demandent de commencer depuis une position connue.

C'est pourquoi la pagination par curseur reste rapide pour les listes qui avancent. Avec un bon index, la base de données peut sauter à « éléments après X » et lire ensuite les limit lignes suivantes. Avec les offsets, le serveur doit souvent parcourir (ou au moins ignorer) de plus en plus de lignes à mesure que l'offset augmente.

Pour le comportement UI, la pagination par curseur rend le “Suivant” naturel : vous prenez le curseur retourné et le renvoyez sur la requête suivante. “Précédent” est optionnel et plus délicat. Certaines API supportent un curseur before, d'autres récupèrent à l'envers et inversent les résultats.

Quand choisir curseur, offset ou un hybride

Cursor paging without complexity
Implement cursor pagination logic visually with drag-and-drop business processes.
Try Now

Le choix commence par la façon dont les gens utilisent réellement la liste.

La pagination par curseur convient le mieux quand les utilisateurs vont surtout vers l'avant et que la rapidité est primordiale : journaux d'activité, chats, commandes, tickets, traces d'audit, et la plupart des scrolls infinis mobiles. Elle se comporte aussi mieux quand de nouvelles lignes sont insérées ou supprimées pendant que quelqu'un parcourt la liste.

La pagination par offset a du sens quand les utilisateurs sautent fréquemment : tableaux d'administration classiques avec numéros de pages, accès direct à une page, et navigation avant/arrière rapide. C'est simple à expliquer, mais ça peut ralentir sur de grands jeux de données et être moins stable quand les données changent sous vos yeux.

Une façon pratique de décider :

  • Choisissez le curseur quand l'action principale est “suivant, suivant, suivant.”
  • Choisissez l'offset quand “aller à la page N” est une exigence réelle.
  • Considérez les totaux comme optionnels. Les totaux précis peuvent être coûteux sur d'énormes tables.

Les hybrides sont courants. Une approche consiste en un next/prev basé sur curseur pour la vitesse, plus un mode de saut de page optionnel pour des sous-ensembles filtrés petits où les offsets restent rapides. Une autre est la récupération par curseur avec numéros de page basés sur un instantané mis en cache, de sorte que le tableau paraît familier sans transformer chaque requête en un travail lourd.

Un contrat d'API cohérent qui fonctionne sur web et mobile

Les UIs d'administration paraissent plus rapides quand chaque endpoint de liste se comporte de la même façon. L'UI peut changer (tableau web avec numéros de page, liste mobile infinie), mais le contrat d'API doit rester stable pour que vous n'ayez pas à réapprendre les règles de pagination pour chaque écran.

Un contrat pratique a trois parties : les lignes, l'état de pagination, et les totaux optionnels. Gardez les noms identiques entre les endpoints (tickets, users, orders), même si le mode de pagination sous-jacent diffère.

Voici une forme de réponse qui fonctionne bien pour le web et le mobile :

{
  "data": [ { "id": "...", "createdAt": "..." } ],
  "page": {
    "mode": "cursor",
    "limit": 50,
    "nextCursor": "...",
    "prevCursor": null,
    "hasNext": true,
    "hasPrev": false
  },
  "totals": {
    "count": 12345,
    "filteredCount": 120
  }
}

Quelques détails rendent cela réutilisable :

  • page.mode indique au client ce que fait le serveur sans changer les noms de champs.
  • limit est toujours la taille de page demandée.
  • nextCursor et prevCursor sont présents même si l'un est null.
  • totals est optionnel. S'il est coûteux, ne le renvoyez que lorsque le client le demande.

Un tableau web peut toujours afficher “Page 3” en gardant son propre index de page et en appelant l'API de façon répétée. Une application mobile peut ignorer les numéros de page et simplement demander le morceau suivant.

Si vous construisez à la fois des UIs web et mobiles administratives avec AppMaster, un contrat stable comme celui-ci rapporte vite. Le même comportement de liste peut être réutilisé entre écrans sans logique de pagination personnalisée par endpoint.

Règles de tri qui gardent la pagination stable

Make admin screens feel instant
Create an admin table backend that stays responsive as your dataset grows.
Start Project

Le tri est l'endroit où la pagination casse généralement. Si l'ordre peut changer entre les requêtes, les utilisateurs voient des doublons, des trous ou des lignes “manquantes”.

Faites du tri un contrat, pas une suggestion. Publiez les champs de tri autorisés et les directions, et rejetez tout le reste. Cela rend votre API prévisible et empêche les clients de demander des tris lents qui semblent inoffensifs en développement.

Un tri stable nécessite un bris d'égalité unique. Si vous triez par created_at et que deux enregistrements ont le même timestamp, ajoutez id (ou une autre colonne unique) comme dernière clé de tri. Sans cela, la base de données est libre de retourner les valeurs égales dans n'importe quel ordre.

Règles pratiques qui tiennent la route :

  • Autorisez le tri seulement sur des champs indexés et bien définis (par exemple created_at, updated_at, status, priority).
  • Incluez toujours un bris d'égalité unique en dernier (par exemple id ASC).
  • Définissez un tri par défaut (par exemple created_at DESC, id DESC) et gardez-le cohérent entre les clients.
  • Documentez la façon dont les nulls sont triés (par exemple “nulls last” pour les dates et nombres).

Le tri influence aussi la génération du curseur. Un curseur doit encoder les valeurs de tri du dernier élément dans l'ordre, y compris le bris d'égalité, afin que la page suivante puisse interroger “after” ce tuple. Si le tri change, les anciens curseurs deviennent invalides. Traitez les paramètres de tri comme faisant partie du contrat du curseur.

Filtres et totaux sans casser le contrat

Les filtres devraient être distincts de la pagination. L'UI dit “montre-moi un ensemble différent de lignes”, puis elle demande “page à travers cet ensemble”. Si vous mélangez les champs de filtre dans votre jeton de pagination ou traitez les filtres comme optionnels et non validés, vous obtenez des comportements difficiles à déboguer : pages vides, doublons, ou un curseur qui pointe soudainement vers un autre jeu de données.

Une règle simple : les filtres vivent dans des paramètres de requête normaux (ou le corps de la requête pour un POST), et le curseur est opaque et n'est valide que pour cette combinaison exacte de filtre et de tri. Si l'utilisateur change un filtre (status, plage de dates, assignee), le client doit abandonner l'ancien curseur et repartir du début.

Soyez strict sur les filtres autorisés. Cela protège les performances et garde le comportement prévisible :

  • Rejetez les champs de filtre inconnus (ne les ignorez pas silencieusement).
  • Validez les types et plages (dates, enums, IDs).
  • Limitez les filtres larges (par exemple, max 50 IDs dans une liste IN).
  • Appliquez les mêmes filtres aux données et aux totaux (pas de chiffres incohérents).

Les totaux sont souvent la partie lente des APIs. Les comptes exacts peuvent être coûteux sur de grandes tables, surtout avec plusieurs filtres. Vous avez généralement trois options : exact, estimé, ou aucun. Exact est utile pour les petits jeux de données ou quand l'utilisateur a vraiment besoin de “affiche 1–25 de 12 431”. Estimé suffit souvent pour les écrans d'administration. Aucun est acceptable quand vous n'avez besoin que de “Charger plus.”

Pour éviter d'alourdir chaque requête, rendez les totaux optionnels : calculez-les seulement quand le client le demande (par exemple avec un flag includeTotal=true), mettez-les en cache brièvement par jeu de filtres, ou retournez les totaux seulement sur la première page.

Étape par étape : concevoir et implémenter l'endpoint

One API for web and mobile
Use one pagination contract across web and native mobile screens.
Create App

Commencez par des valeurs par défaut. Un endpoint de liste a besoin d'un ordre de tri stable, plus un bris d'égalité pour les lignes qui partagent la même valeur. Par exemple : createdAt DESC, id DESC. Le bris d'égalité (id) empêche les doublons et les trous quand de nouveaux enregistrements sont ajoutés.

Définissez une forme de requête et gardez-la simple. Les paramètres typiques sont limit, cursor (ou offset), sort et filters. Si vous supportez les deux modes, rendez-les mutuellement exclusifs : soit le client envoie cursor, soit il envoie offset, mais pas les deux.

Gardez un contrat de réponse cohérent pour que les UIs web et mobiles puissent partager la même logique de liste :

  • items : la page d'enregistrements
  • nextCursor : le curseur pour obtenir la page suivante (ou null)
  • hasMore : booléen pour que l'UI sache si elle affiche “Charger plus”
  • total : total des enregistrements correspondants (null sauf si demandé, si le comptage est coûteux)

L'implémentation est là où les deux approches divergent.

Les requêtes offset sont généralement ORDER BY ... LIMIT ... OFFSET ..., ce qui peut ralentir sur de grandes tables.

Les requêtes par curseur utilisent des conditions de recherche basées sur le dernier élément : “donne-moi les éléments où (createdAt, id) est inférieur au dernier (createdAt, id)”. Cela maintient des performances plus stables car la base de données peut utiliser les index.

Avant la mise en production, ajoutez des garde-fous :

  • Limitez limit (par exemple, max 100) et définissez une valeur par défaut.
  • Validez sort contre une allowlist.
  • Validez les filtres par type et refusez les clés inconnues.
  • Rendez le cursor opaque (encodez les dernières valeurs de tri) et rejetez les curseurs malformés.
  • Décidez comment total est demandé.

Testez avec des données qui changent pendant la navigation. Créez et supprimez des enregistrements entre les requêtes, mettez à jour des champs qui affectent le tri, et vérifiez que vous ne voyez pas de doublons ni de lignes manquantes.

Exemple : une liste de tickets rapide sur web et mobile

Ship the backend first
Model data in PostgreSQL and generate a production-ready backend in Go.
Build Backend

Une équipe de support ouvre un écran d'administration pour revoir les tickets les plus récents. Ils ont besoin que la liste paraisse instantanée, même quand de nouveaux tickets arrivent et que des agents mettent à jour d'anciens tickets.

Sur le web, l'UI est un tableau. Le tri par défaut est updated_at (les plus récents en premier), et l'équipe filtre souvent sur Open ou Pending. Le même endpoint peut supporter les deux usages avec un tri stable et un jeton curseur.

GET /tickets?status=open\u0026sort=-updated_at\u0026limit=50\u0026cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==

La réponse reste prévisible pour l'UI :

{
  "items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
  "page": {"next_cursor": "...", "has_more": true},
  "meta": {"total": 128}
}

Sur mobile, le même endpoint alimente le scroll infini. L'app charge 20 tickets à la fois, puis envoie next_cursor pour récupérer la lot suivant. Pas de logique de numéro de page, et moins de surprises quand les enregistrements changent.

L'important est que le curseur encode la dernière position vue (par exemple updated_at plus id comme bris d'égalité). Si un ticket est mis à jour pendant que l'agent défile, il peut remonter en haut au prochain rafraîchissement, mais cela ne provoquera pas de doublons ni de trous dans le flux déjà parcouru.

Les totaux sont utiles, mais coûteux sur les grands jeux de données. Une règle simple est de ne renvoyer meta.total que lorsque l'utilisateur applique un filtre (comme status=open) ou le demande explicitement.

Erreurs fréquentes qui causent doublons, trous et lenteur

La plupart des bugs de pagination ne viennent pas de la base de données. Ils proviennent de petites décisions d'API qui semblent correctes en test, puis s'effondrent quand les données changent entre les requêtes.

La cause la plus courante de doublons (ou de lignes manquantes) est le tri sur un champ qui n'est pas unique. Si vous triez par created_at et que deux éléments partagent le même timestamp, l'ordre peut s'inverser entre les requêtes. La solution est simple : ajoutez toujours un bris d'égalité stable, généralement la clé primaire, et traitez le tri comme une paire comme (created_at desc, id desc).

Un autre problème fréquent est de laisser les clients demander n'importe quelle taille de page. Une grosse requête peut faire monter l'utilisation CPU, mémoire, et les temps de réponse, ce qui ralentit chaque écran d'administration. Choisissez une valeur par défaut raisonnable et un maximum strict, et renvoyez une erreur quand le client en demande trop.

Les totaux peuvent aussi nuire. Compter toutes les lignes correspondantes à chaque requête peut devenir la partie la plus lente de l'endpoint, surtout avec des filtres. Si l'UI a besoin des totaux, récupérez-les seulement quand c'est demandé (ou renvoyez une estimation), et évitez de bloquer le défilement sur un comptage complet.

Erreurs qui créent le plus souvent des trous, doublons et lenteur :

  • Trier sans bris d'égalité unique (ordre instable)
  • Tailles de pages illimitées (surcharge serveur)
  • Retourner les totaux à chaque fois (requêtes lentes)
  • Mixer les règles offset et curseur dans un même endpoint (comportement client confus)
  • Réutiliser le même curseur quand les filtres ou le tri changent (résultats erronés)

Réinitialisez la pagination chaque fois que les filtres ou le tri changent. Traitez un nouveau filtre comme une nouvelle recherche : effacez le curseur/offset et repartez de la première page.

Liste de contrôle rapide avant la mise en production

Optimize mobile lists
Power smooth infinite scroll with fast, predictable paging from the same backend.
Create Mobile

Faites ce contrôle une fois avec l'API et l'UI côte à côte. La plupart des problèmes surviennent dans le contrat entre l'écran de liste et le serveur.

  • Le tri par défaut est stable et inclut un bris d'égalité unique (par exemple created_at DESC, id DESC).
  • Les champs et directions de tri sont en whitelist.
  • Une taille de page maximale est appliquée, avec une valeur par défaut sensée.
  • Les jetons de curseur sont opaques, et les curseurs invalides échouent de façon prévisible.
  • Tout changement de filtre ou de tri réinitialise l'état de pagination.
  • Le comportement des totaux est explicite : exact, estimé, ou omis.
  • Le même contrat supporte à la fois un tableau et le scroll infini sans cas particuliers.

Étapes suivantes : standardisez vos listes et gardez-les cohérentes

Choisissez une liste d'administration que les gens utilisent tous les jours et faites-en votre standard doré. Un tableau fréquent comme Tickets, Orders ou Users est un bon point de départ. Une fois que cet endpoint est rapide et prévisible, appliquez le même contrat sur le reste de vos écrans d'administration.

Écrivez le contrat, même brièvement. Soyez explicite sur ce que l'API accepte et retourne pour que l'équipe UI ne devine pas et n'invente pas des règles différentes par endpoint.

Un standard simple à appliquer à chaque endpoint de liste :

  • Tris autorisés : noms de champs exacts, direction, et un défaut clair (plus un bris d'égalité comme id).
  • Filtres autorisés : quels champs peuvent être filtrés, formats de valeurs, et comportement sur filtres invalides.
  • Comportement des totaux : quand renvoyer un compte exact, quand renvoyer “inconnu”, et quand l'omettre.
  • Forme de réponse : clés cohérentes (items, info de pagination, tri/filtres appliqués, totaux).
  • Règles d'erreur : codes de statut cohérents et messages de validation lisibles.

Si vous construisez ces écrans d'administration avec AppMaster (appmaster.io), il est utile de standardiser le contrat de pagination tôt. Vous pouvez réutiliser le même comportement de liste sur votre application web et vos apps mobiles natives, et passer moins de temps à corriger des cas limites de pagination plus tard.

FAQ

What’s the real difference between offset and cursor pagination?

Offset pagination uses limit plus offset (or page/pageSize) to skip rows, so deeper pages often get slower as the database has to walk past more records. Cursor pagination uses an after token based on the last item’s sort values, so it can jump to a known position and stay fast as you keep moving forward.

Why does my admin table feel slower the more pages I go through?

Because page 1 is usually cheap, but page 200 forces the database to skip a large number of rows before it can return anything. If you also sort and filter, the work grows, so each click feels more like a new heavy query than a quick fetch.

How do I prevent duplicates or missing rows when users paginate?

Always use a stable sort with a unique tie-breaker, such as created_at DESC, id DESC or updated_at DESC, id DESC. Without the tie-breaker, records with the same timestamp can swap order between requests, which is a common cause of duplicates and “missing” rows.

When should I prefer cursor pagination?

Use cursor pagination for lists where people mostly move forward and speed matters, like activity logs, tickets, orders, and mobile infinite scroll. It stays consistent when new rows are inserted or deleted, because the cursor anchors the next page to an exact last-seen position.

When does offset pagination still make sense?

Offset pagination fits best when “jump to page N” is a real UI feature and users regularly bounce around. It’s also convenient for small tables or stable datasets, where deep-page slowdown and shifting results are unlikely to matter.

What should a consistent pagination API response include?

Keep one response shape across endpoints and include the items, paging state, and optional totals. A practical default is returning items, a page object (with limit, nextCursor/prevCursor or offset), and a lightweight flag like hasNext so both web tables and mobile lists can reuse the same client logic.

Why can totals make pagination slow, and what’s a safer default?

Because exact COUNT(*) on large, filtered datasets can become the slowest part of the request and make every page change feel laggy. A good default is to make totals optional, return them only when requested, or return has_more when the UI only needs “Load more.”

What should happen to the cursor when filters or sorting changes?

Treat filters as part of the dataset, and treat the cursor as valid only for that exact filter and sort combination. If a user changes any filter or sort, reset pagination and start from the first page; reusing an old cursor after changes is a common way to get empty pages or confusing results.

How do I make sorting fast and predictable for pagination?

Whitelist allowed sort fields and directions, and reject anything else so clients can’t accidentally request slow or unstable ordering. Prefer sorting on indexed fields and always append a unique tie-breaker like id to keep the order deterministic across requests.

What guardrails should I add before shipping a pagination endpoint?

Enforce a maximum limit, validate filters and sort parameters, and make cursor tokens opaque and strictly validated. If you’re building admin screens in AppMaster, keeping these rules consistent across all list endpoints makes it easier to reuse the same table and infinite-scroll behavior without custom pagination fixes per screen.

Facile à démarrer
Créer quelque chose d'incroyable

Expérimentez avec AppMaster avec un plan gratuit.
Lorsque vous serez prêt, vous pourrez choisir l'abonnement approprié.

Démarrer