Cursor Pagination — een methode voor het pagineren van gegevens die een unieke cursor gebruikt om door een geordende set records te navigeren. Volgens GraphQL Specification (2025) is cursor-paginering de aanbevolen standaard voor API's die met dynamische gegevens werken. Cursor Pagination elimineert de belangrijkste nadelen van de Offset-benadering: instabiliteit bij het invoegen en prestatieverlies bij grote verschuivingen.
Belangrijkste punten
Cursor Pagination (cursor-paginering) — een pagineringsmethode waarbij de server samen met de gegevens een speciale aanwijzer — cursor — retourneert. De client gebruikt deze cursor in het volgende verzoek om het volgende deel van records op te halen. De cursor is de unieke identificatie van het laatste element van de huidige pagina.
In tegenstelling tot Offset-paginering, waar de client zegt „geef me pagina 5 van 20 records”, werkt cursor-paginering anders: „geef me 20 records na het record met ID = 83”. De server voert een query uit met de voorwaarde WHERE id > 83 en LIMIT 20. Deze benadering garandeert dat elk record precies op één pagina terechtkomt, ongeacht het invoegen.
Het concept van cursor-paginering is wijdverspreid geworden dankzij de specificatie Relay Connection (GraphQL), die cursor-based pagination tot standaard heeft gemaakt voor moderne API's. Relay definieert het antwoordformaat: edges (array van records met cursors), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Cursor-pagination is geen nieuwe techniek — het werd al lang vóór het verschijnen van het web in databases gebruikt. In SQL heet dit keyset pagination of seek method. De methode werd populair in API's na de publicatie van de Relay-specificatie in 2015, die het cursorformaat formaliseerde als base64-gecodeerde string voor uniformiteit van overdracht via HTTP.
Het basisprincipe van cursor-paginering — de query gebruikt een WHERE-voorwaarde op een geïndexeerd veld voor positionering, niet een verschuiving. Voor voorwaartse richting wordt WHERE id > last_id toegepast, voor achterwaartse richting — WHERE id < first_id. De B-tree index vindt het eerste record na de cursor in O(log n), wat een stabiele responstijd geeft.
-- 20 records ophalen na cursor '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- 20 records ophalen VÓÓR cursor '83' (terug)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
De cursor kan eenvoudig (ID-waarde) of complex (samengesteld uit meerdere velden) zijn. Eenvoudige cursors zijn de primaire sleutel van het record, bijvoorbeeld auto-increment id of UUID. Complexe cursors worden gebruikt voor sortering op niet-unieke velden, bijvoorbeeld (created_at, id), waarbij id uniciteit garandeert bij identieke tijdstempels.
Typisch API-formaat — cursor als base64-gecodeerde string. De server decodeert de cursor, haalt de waarde eruit en bouwt de SQL-query. Base64-codering verbergt de interne structuur van de cursor voor de client en maakt het mogelijk het formaat te wijzigen zonder achterwaartse compatibiliteit te verliezen. De client ontvangt cursors in het endCursor-veld van het antwoord en geeft ze door als string in het volgende verzoek.
Cursor-paginering ondersteunt bidirectionele navigatie. Voor voorwaartse beweging (next) wordt de cursor van het laatste element van de huidige pagina gebruikt, voor achteruit (previous) — de cursor van het eerste element. De parameters after en before in het verzoek bepalen de richting: after haalt records na de cursor op, before — vóór de cursor.
De keuze tussen cursor- en Offset-paginering is een van de belangrijkste architecturale vragen bij het ontwerpen van API's. Elke methode heeft sterke en zwakke punten. Cursor-pagination wint in scenario's met dynamische gegevens, Offset — in scenario's met willekeurige navigatie.
| Kenmerk | Cursor | Offset |
|---|---|---|
| Stabiliteit bij invoegen | Hoog (geen duplicaten) | Laag (pagina-verschuiving) |
| Prestaties op grote sets | O(log n) — stabiel | O(n) — neemt af met groei |
| Navigatie op paginanummer | Nee | Ja (page=5) |
| Complexiteit van implementatie | Gemiddeld | Laag |
| Ondersteuning in REST | cursor/before/after | page/offset |
| Ondersteuning in GraphQL | Relay-standaard | Niet aanbevolen |
Offset-paginering voert een volledige tabelscan uit tot de OFFSET-positie. Bij offset=100000 leest en overslaat de database 100000 rijen, zelfs als LIMIT 20 is. MySQL en PostgreSQL kunnen OFFSET niet optimaliseren — dit is een kenmerk van de LIMIT/OFFSET-implementatie in SQL. Cursor-pagination gebruikt de B-tree index, die de positie vindt in O(log n).
Een aanvullend probleem van Offset — „overslaan” van records bij achterwaartse paginering. Als de gebruiker pagina 5 heeft geladen en er worden op dat moment nieuwe records toegevoegd, ziet hij bij het opvragen van pagina 6 het record van pagina 5 opnieuw of slaat hij nieuwe over. Cursor-pagination elimineert dit scenario volledig: de cursor wijst naar een specifieke plaats in de set en invoegingen veranderen de positie niet.
Laten we de implementatie van cursor-paginering op de backend (Kotlin + Spring) en op de client (Android + Retrofit) bekijken. De server ontvangt parameters after, before, limit en retourneert een lijst van records met cursors en pageInfo. Een typisch antwoord bevat hasNextPage en hasPreviousPage voor het beheren van de paginerings-UI.
@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
)
)
}
Aan de clientzijde wordt cursor-paginering geïmplementeerd via PagingSource uit Paging 3, waarbij de sleutel de cursor (Long) is. PagingSource.load ontvangt LoadParams.key — de cursor van het laatst geladen record. LoadResult.Page retourneert gegevens en nextKey — de cursor voor de volgende pagina. Wanneer nextKey = null — is de paginering voltooid.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource met cursorsleutel
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)
}
}
In GraphQL wordt cursor-paginering geïmplementeerd via het Connection Relay-patroon. Elk type heeft Connection (met pageInfo en edges) en Edge (node + cursor). Het verzoek geeft parameters first, after, last, before door. De server retourneert een array van edges met cursors en pageInfo met hasNextPage/hasPreviousPage.
Cursor-pagination wordt aanbevolen voor API's die werken met dynamische gegevens, waar records vaak worden toegevoegd of verwijderd. Klassieke voorbeelden: nieuwsfeed in een sociaal netwerk, chatberichten, transactiegeschiedenis, reacties op berichten. In al deze scenario's zijn consistentie en afwezigheid van duplicaten belangrijk.
Er zijn scenario's waarin Offset-paginering handiger is: beheerpanelen waar navigatie op paginanummer nodig is; zoeken met paginering waar resultaten kunnen veranderen; rapporten en analyses waar een vaste link naar pagina 5 nodig is. In deze gevallen wegen de voordelen van de cursor niet op tegen de complexiteit van de implementatie.
Cursor-paginering ondersteunt geen „sprong” naar een willekeurige pagina — de gebruiker kan niet op „Pagina 5” klikken en er naartoe gaan. Dit is een architecturale beperking: voor het berekenen van het totale aantal pagina's is een aparte COUNT-query nodig, die duur kan zijn voor grote tabellen. In dergelijke gevallen een hybride benadering: cursor voor gegevens + count voor paginering.
Veelgestelde vragen
Een cursor is de unieke identificatie van een record die de positie in de dataset aangeeft. Het kan eenvoudig zijn (record-ID) of complex (meerdere velden). De client ontvangt de cursor van het laatste record van de pagina en geeft deze door in het volgende verzoek om het volgende deel te krijgen.
Cursor-pagination is niet onderhevig aan verschuiving bij het toevoegen van nieuwe records — elk element komt precies op één pagina terecht. Het behoudt ook snelheid op grote volumes door het gebruik van indexen in plaats van het scannen van de eerste n rijen. Offset is eenvoudiger, maar onstabiel voor dynamische gegevens.
Ja, cursor-paginering is niet gebonden aan GraphQL. Het kan worden geïmplementeerd in elke REST API door de cursor als queryparameter door te geven ?after=83&limit=20. Het antwoord moet pageInfo bevatten met endCursor en hasNextPage — dit stelt de client in staat het laden te beheren zonder kennis van de interne cursorstructuur.
Auto-increment ID — de optimale keuze: stijgt monotoon, verandert niet, wordt efficiënt geïndexeerd. UUID v7 (tijd-geordend) is ook geschikt. Timestamp kan duplicaten geven bij dezelfde tijd, daarom wordt het gecombineerd met ID: (created_at, id) voor garantie van cursoruniciteit.
Cursor-paginering geeft geen totaal aantal pagina's — dit is de beperking ervan. Als informatie over total nodig is, voer dan een aparte COUNT-query uit met dezelfde filters. Gebruik voor grote tabellen een benaderende telling via EXPLAIN of een gecachte total uit analyses.
Samenvatting
We ontwikkelen een mobiele applicatie turnkey
IT Sectr creëert sinds 2017 iOS- en Android-applicaties voor startups en bedrijven. We adviseren u en stellen de beste oplossing voor.
Lees ook