Cursor Pagination est une méthode de chargement paginé des données qui utilise un curseur unique pour naviguer dans un ensemble ordonné d’enregistrements. Selon la Spécification GraphQL (2025), la pagination basée sur un curseur est la norme recommandée pour les API travaillant avec des données dynamiques. Cursor pagination élimine les principaux inconvénients de l’approche Offset : instabilité lors des insertions et dégradation des performances sur les grands décalages.
Points clés
Cursor Pagination est une méthode de chargement paginé où le serveur renvoie avec les données un pointeur spécial — un curseur. Le client utilise ce curseur dans la requête suivante pour obtenir le lot suivant d’enregistrements. Le curseur est un identifiant unique du dernier élément de la page actuelle.
Contrairement à la pagination Offset, où le client dit « donne-moi la page 5 avec 20 enregistrements », la pagination par curseur fonctionne différemment : « donne-moi 20 enregistrements après l’enregistrement avec ID = 83 ». Le serveur exécute une requête avec WHERE id > 83 et LIMIT 20. Cette approche garantit que chaque enregistrement tombe exactement dans une page indépendamment des insertions.
Le concept de pagination par curseur a été largement adopté grâce à la spécification Relay Connection (GraphQL), qui a fait de la pagination basée sur un curseur la norme pour les API modernes. Relay définit le format de réponse : edges (tableau d’enregistrements avec curseurs), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
La pagination par curseur n’est pas une technique nouvelle — elle était utilisée dans les bases de données bien avant le web. En SQL, on l’appelle keyset pagination ou seek method. La méthode est devenue populaire dans les API après la publication de la spécification Relay en 2015, qui a formalisé le format du curseur comme une chaîne encodée en base64 pour l’uniformité du transport HTTP.
Le principe de base de la pagination par curseur est que la requête utilise une condition WHERE sur un champ indexé pour le positionnement, et non un décalage. Pour la direction avant, on utilise WHERE id > last_id ; pour la direction arrière, WHERE id < first_id. L’index B-tree trouve le premier enregistrement après le curseur en O(log n), offrant un temps de réponse stable.
-- Récupérer 20 enregistrements après le curseur '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Récupérer 20 enregistrements AVANT le curseur '83' (retour)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Un curseur peut être simple (une valeur ID) ou complexe (composé de plusieurs champs). Les curseurs simples sont la clé primaire d’un enregistrement, par exemple, id auto-incrémenté ou UUID. Les curseurs composites sont utilisés pour le tri par champs non uniques, par exemple (created_at, id), où id garantit l’unicité lorsque les horodatages sont identiques.
Un format d’API typique est un curseur sous forme de chaîne encodée en base64. Le serveur décode le curseur, extrait la valeur et construit la requête SQL. L’encodage Base64 cache la structure interne du curseur au client et permet de modifier le format sans rompre la compatibilité ascendante. Le client reçoit les curseurs dans le champ endCursor de la réponse et les transmet comme chaîne dans la requête suivante.
La pagination par curseur prend en charge la navigation bidirectionnelle. Pour le déplacement avant (next), on utilise le curseur du dernier élément de la page actuelle ; pour l’arrière (previous), le curseur du premier élément. Les paramètres after et before dans la requête déterminent la direction : after prend les enregistrements après le curseur, before prend les enregistrements avant le curseur.
Le choix entre la pagination par curseur et par Offset est l’une des décisions architecturales clés lors de la conception d’une API. Chaque méthode a des forces et des faiblesses qui déterminent son applicabilité. Cursor pagination l’emporte dans les scénarios avec des données dynamiques ; Offset l’emporte dans les scénarios avec navigation arbitraire.
| Caractéristique | Cursor | Offset |
|---|---|---|
| Stabilité lors des insertions | Élevée (pas de doublons) | Faible (décalage de pages) |
| Performance sur grands ensembles | O(log n) — stable | O(n) — se dégrade avec la croissance |
| Navigation par numéro de page | Non | Oui (page=5) |
| Complexité d’implémentation | Moyenne | Faible |
| Support REST | cursor/before/after | page/offset |
| Support GraphQL | Norme Relay | Non recommandé |
La pagination Offset effectue un balayage complet de la table jusqu’à la position OFFSET. À offset=100000, la base de données lit et saute 100000 lignes, même si LIMIT est 20. MySQL et PostgreSQL ne peuvent pas optimiser OFFSET — c’est une caractéristique d’implémentation de LIMIT/OFFSET en SQL. La pagination par curseur utilise un index B-tree qui trouve la position en O(log n).
Un problème supplémentaire d’Offset est le « saut » d’enregistrements lors de la pagination arrière. Si un utilisateur a chargé la page 5 et que de nouveaux enregistrements ont été ajoutés à ce moment-là, lors de la demande de la page 6, il verra à nouveau l’enregistrement de la page 5 ou manquera les nouveaux. Cursor pagination élimine complètement ce scénario : le curseur pointe vers un endroit spécifique dans l’ensemble et les insertions ne changent pas la position.
Examinons l’implémentation de la pagination par curseur sur le backend (Kotlin + Spring) et sur le client (Android + Retrofit). Le serveur accepte les paramètres after, before, limit et renvoie une liste d’enregistrements avec curseurs et pageInfo. Une réponse typique contient hasNextPage et hasPreviousPage pour gérer l’interface de pagination.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Sur le client, la pagination par curseur est implémentée via PagingSource de Paging 3, où la clé est un curseur (Long). PagingSource.load reçoit LoadParams.key — le curseur du dernier enregistrement chargé. LoadResult.Page renvoie les données et nextKey — le curseur pour la page suivante. Quand nextKey = null, la pagination est terminée.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Dans GraphQL, la pagination par curseur est implémentée via le modèle Relay Connection. Chaque type a une Connection (avec pageInfo et edges) et un Edge (node + cursor). La requête transmet les paramètres first, after, last, before. Le serveur renvoie un tableau d’edges avec curseurs et pageInfo avec hasNextPage/hasPreviousPage.
La pagination par curseur est recommandée pour les API travaillant avec des données dynamiques où les enregistrements sont fréquemment ajoutés ou supprimés. Exemples classiques : fil d’actualité dans un réseau social, messages de chat, historique des transactions, commentaires de publications. Dans tous ces scénarios, la cohérence et l’absence de doublons sont importantes.
Il existe des scénarios où la pagination Offset est plus pratique : panneaux d’administration où la navigation par numéro de page est nécessaire ; recherche avec pagination où les résultats peuvent changer ; rapports et analyses où un lien fixe vers la page 5 est nécessaire. Dans ces cas, les avantages du curseur ne compensent pas la complexité d’implémentation.
La pagination par curseur ne prend pas en charge le « saut » vers une page arbitraire — l’utilisateur ne peut pas cliquer sur « Page 5 » pour y accéder. C’est une limitation architecturale : compter le nombre total de pages nécessite une requête COUNT séparée, qui peut être coûteuse pour les grandes tables. Dans ces cas, une approche hybride : curseur pour les données + count pour la pagination.
Foire aux questions
Un curseur est un identifiant unique d’enregistrement qui pointe vers une position dans l’ensemble de données. Il peut être simple (un ID d’enregistrement) ou composite (plusieurs champs). Le client reçoit le curseur du dernier enregistrement de la page et le transmet dans la requête suivante pour obtenir le lot suivant.
Cursor pagination n’est pas sujet au décalage lors de l’ajout de nouveaux enregistrements — chaque élément tombe exactement dans une page. Il maintient également la vitesse sur les grands volumes en utilisant des index au lieu de scanner les premières n lignes. Offset est plus simple mais instable pour les données dynamiques.
Oui, la pagination par curseur n’est pas liée à GraphQL. Elle peut être implémentée dans n’importe quelle API REST en passant le curseur comme paramètre de requête ?after=83&limit=20. La réponse doit contenir pageInfo avec endCursor et hasNextPage — cela permet au client de gérer le chargement sans connaître la structure interne du curseur.
ID auto-incrémenté est le choix optimal : il augmente de façon monotone, ne change pas, est indexé efficacement. UUID v7 (ordonné par temps) convient également. Les horodatages peuvent produire des doublons au même moment, donc combinez-le avec ID : (created_at, id) pour garantir l’unicité du curseur.
La pagination par curseur ne fournit pas le nombre total de pages — c’est sa limitation. Si vous avez besoin d’informations totales, exécutez une requête COUNT séparée avec les mêmes filtres. Pour les grandes tables, utilisez un comptage approximatif via EXPLAIN ou un total mis en cache depuis les analyses.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi