Cursor-paginatie houdt lijsten stabiel wanneer gegevens veranderen. Lees waarom offset-paginatie faalt bij invoegingen en verwijderingen en hoe je nette cursors implementeert.

Je opent een feed, scrolt even en alles voelt normaal — totdat het dat niet meer doet. Je ziet hetzelfde item twee keer. Iets waarvan je zwoer dat het er stond, is verdwenen. Een rij waarop je wilde tikken verschuift naar beneden en je belandt op de verkeerde detailpagina.
Dit zijn zichtbare gebruikersbugs, zelfs als je API-responses op zichzelf "correct" lijken. De gebruikelijke symptomen zijn makkelijk te herkennen:
Dit wordt erger op mobiel. Mensen pauzeren, schakelen van app, verliezen verbinding en gaan later verder. In de tussentijd komen er nieuwe items bij, oude worden verwijderd en sommige worden bewerkt. Als je app blijft vragen om “pagina 3” met een offset, kunnen paginagrenzen verschuiven terwijl de gebruiker midden in het scrollen zit. Het resultaat is een feed die onstabiel en onbetrouwbaar aanvoelt.
Het doel is simpel: zodra een gebruiker vooruit begint te scrollen, moet de lijst zich gedragen als een snapshot. Nieuwe items kunnen bestaan, maar ze mogen niet herschikken wat de gebruiker al aan het pagineren is. De gebruiker moet een vloeiende, voorspelbare volgorde krijgen.
Geen enkele paginatiemethode is perfect. Reële systemen hebben gelijktijdige schrijfacties, bewerkingen en meerdere sorteermogelijkheden. Maar cursor-paginatie is meestal veiliger dan offset-paginatie omdat het pagineert vanuit een specifieke positie in een stabiele volgorde, in plaats van vanaf een verschuivend rijnummer.
Offset-paginatie is de “sla N over, neem M” manier om door een lijst te bladeren. Je vertelt de API hoeveel items je wilt overslaan (offset) en hoeveel je wilt retourneren (limit). Met limit=20 krijg je 20 items per pagina.
Conceptueel:
GET /items?limit=20\u0026offset=0 (eerste pagina)GET /items?limit=20\u0026offset=20 (tweede pagina)GET /items?limit=20\u0026offset=40 (derde pagina)De response bevat meestal de items plus genoeg informatie om de volgende pagina op te vragen.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Het is populair omdat het mooi aansluit op tabellen, admin-lijsten, zoekresultaten en eenvoudige feeds. Het is ook makkelijk te implementeren in SQL met LIMIT en OFFSET.
De valkuil is de verborgen aanname: de dataset blijft stil staan terwijl de gebruiker pagina's ophaalt. In echte apps bewegen lijsten. Nieuwe rijen worden ingevoegd, rijen verwijderd en sorteersleutels veranderen. Daar beginnen de “mysteriebugs”.
Offset-paginatie gaat ervan uit dat de lijst tussen verzoeken stil blijft. Maar reële lijsten bewegen. Wanneer de lijst verschuift, verwijst een offset zoals “sla 20 over” niet langer naar dezelfde items.
Stel je een feed voor gesorteerd op created_at desc (nieuwste eerst), paginagrootte 3.
Je laadt pagina 1 met offset=0, limit=3 en krijgt [A, B, C].
Nu wordt een nieuw item X aangemaakt en verschijnt bovenaan. De lijst is nu [X, A, B, C, D, E, F, ...]. Je laadt pagina 2 met offset=3, limit=3. De server slaat [X, A, B] over en retourneert [C, D, E].
Je zag net C opnieuw (een duplicaat), en later mis je een item omdat alles omlaag schuift.
Verwijderingen veroorzaken het omgekeerde falen. Begin met [A, B, C, D, E, F, ...]. Je laadt pagina 1 en ziet [A, B, C]. Voordat je pagina 2 ophaalt, wordt B verwijderd, dus de lijst wordt [A, C, D, E, F, ...]. Pagina 2 met offset=3 slaat [A, C, D] over en retourneert [E, F, G]. D wordt een gap die je nooit ophaalt.
In newest-first feeds gebeuren invoegingen bovenaan, en dat is precies wat elke latere offset verschuift.
Een “stabiele lijst” is wat gebruikers verwachten: terwijl ze vooruit scrollen, springen items niet rond, herhalen ze zich niet en verdwijnen ze niet zonder duidelijke reden. Het gaat minder om het bevriezen van tijd en meer om paginatie voorspelbaar maken.
Twee ideeën worden vaak door elkaar gehaald:
created_at met een tie-breaker zoals id) zodat twee verzoeken met dezelfde inputs dezelfde volgorde teruggeven.Vernieuwen en vooruit scrollen zijn verschillende acties. Vernieuwen betekent “laat me zien wat er nu nieuw is”, dus de top kan veranderen. Vooruit scrollen betekent “ga door vanaf waar ik was”, dus je zou geen duplicaten of onverwachte gaten moeten zien veroorzaakt door verschuivende paginagrenzen.
Een simpele regel die de meeste paginatiebugs voorkomt: vooruit scrollen mag nooit duplicaten tonen.
Cursor-paginatie beweegt door een lijst met een bladwijzer in plaats van een paginanummer. In plaats van “geef pagina 3”, zegt de client “ga door vanaf hier”.
Het contract is helder:
Dit verdraagt invoegingen en verwijderingen beter omdat de cursor ankerend is op een positie in de gesorteerde lijst, niet op een rijnummer.
De niet-onderhandelbare vereiste is een deterministische sorteervolgorde. Je hebt een stabiele ordeningsregel en een consistente tie-breaker nodig, anders is de cursor geen betrouwbare bladwijzer.
Begin met het kiezen van één sorteerwijze die past bij hoe mensen de lijst lezen. Feeds, berichten en activiteitslogboeken zijn meestal nieuwste eerst. Historieken zoals facturen en auditlogs zijn vaak handiger oudste eerst.
Een cursor moet een positie in die volgorde uniek identificeren. Als twee items dezelfde cursorwaarde kunnen delen, krijg je uiteindelijk duplicaten of gaten.
Veelgebruikte keuzes en waar je op moet letten:
created_at alleen: eenvoudig, maar onveilig als veel rijen dezelfde timestamp delen.id alleen: veilig als IDs monotoon oplopen, maar het geeft mogelijk niet de productorde die je wilt.created_at + id: meestal de beste mix (timestamp voor productorde, id als tie-breaker).updated_at als primaire sortering: riskant voor infinite scroll omdat bewerkingen items tussen pagina's kunnen verplaatsen.Als je meerdere sorteermodi aanbiedt, behandel elke sortering als een andere lijst met eigen cursorregels. Een cursor heeft alleen zin voor één exacte ordening.
Je kunt de API-oppervlakte klein houden: twee inputs, twee outputs.
Stuur een limit (hoeveel items je wilt) en een optionele cursor (waar je vandaan doorgaat). Als de cursor ontbreekt, retourneert de server de eerste pagina.
Voorbeeldverzoek:
GET /api/messages?limit=30\u0026cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Retourneer de items en een next_cursor. Als er geen volgende pagina is, geef next_cursor: null. Clients moeten de cursor als een token behandelen, niet iets om te bewerken.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Server-side logica in gewone woorden: sorteer in een stabiele volgorde, filter met de cursor en pas vervolgens de limit toe.
Als je sorteert newest-first op (created_at DESC, id DESC), decodeer de cursor naar (created_at, id), en haal dan rijen op waar (created_at, id) strikt kleiner is dan het cursor-paar, pas dezelfde volgorde toe en neem limit rijen.
Je kunt de cursor encoderen als een base64 JSON-blok (makkelijk) of als een ondertekend/versleuteld token (meer werk). Opaque is veiliger omdat het je toestaat intern later te wijzigen zonder clients te breken.
Zet ook verstandige defaults: een redelijke mobiele default (vaak 20–30), een web-default (vaak 50) en een harde server-max zodat één buggy client niet 10.000 rijen kan opvragen.
Een stabiele feed draait vooral om één belofte: zodra de gebruiker vooruit begint te scrollen, mogen items die hij nog niet zag niet gaan stuiteren omdat iemand anders records heeft aangemaakt, verwijderd of bewerkt.
Met cursor-paginatie zijn invoegingen het makkelijkst. Nieuwe records horen bij een refresh te verschijnen, niet middenin reeds geladen pagina's. Als je sorteert op created_at DESC, id DESC, leven nieuwe items natuurlijk voor de eerste pagina, dus je bestaande cursor gaat gewoon door naar oudere items.
Verwijderingen zouden de lijst niet moeten herschikken. Als een item verwijderd is, wordt het simpelweg niet meer geretourneerd wanneer je het zou ophalen. Als je paginagroottes consistent wilt houden, blijf dan ophalen totdat je limit zichtbare items verzamelt.
Bewerkingen zijn waar teams per ongeluk bugs weer binnenhalen. De kernvraag is: kan een bewerking de sorteervolgorde veranderen?
Snapshot-gedrag is meestal het beste voor scrollende lijsten: pagineer op een onveranderlijke sleutel zoals created_at. Bewerkingen mogen de inhoud veranderen, maar het item springt niet naar een nieuwe positie.
Live-feed-gedrag sorteert op iets als edited_at. Dat kan sprongen veroorzaken (een oud item wordt bewerkt en gaat naar de top). Als je hiervoor kiest, behandel de lijst als voortdurend veranderend en ontwerp de UX rond verversen.
Maak de cursor niet afhankelijk van “vind exact die rij”. Encodeer in plaats daarvan de positie, zoals {created_at, id} van het laatst geretourneerde item. Dan is de volgende query gebaseerd op waarden, niet op het bestaan van een rij:
WHERE (created_at, id) \u003c (:created_at, :id)id) om duplicaten te vermijdenVooruit pagineren is het makkelijke deel. Moeilijkere UX-vragen zijn terug pagineren, refresh en willekeurige toegang.
Voor terug pagineren werken twee benaderingen meestal goed:
next_cursor voor oudere items en prev_cursor voor nieuwere items) terwijl je één on-screen sorteerorde behoudt.Willekeurig springen is lastiger met cursors omdat “pagina 20” geen stabiele betekenis heeft wanneer de lijst verandert. Als je echt moet springen, spring dan naar een anchor zoals “rond deze timestamp” of “start vanaf dit message id”, niet naar een paginanummer.
Op mobiel telt caching. Sla cursors op per lijsttoestand (query + filters + sort) en behandel elke tab/view als een eigen lijst. Dat voorkomt dat “tab wisselen en alles raakt in de war”.
De meeste cursor-paginatieproblemen gaan niet over de database. Ze komen door kleine inconsistenties tussen verzoeken die alleen onder echte traffic zichtbaar worden.
De grootste boosdoeners:
created_at) zodat ties duplicaten of ontbrekende items veroorzaken.next_cursor retourneren die niet overeenkomt met het laatste daadwerkelijk geretourneerde item.Als je apps bouwt op platforms zoals Koder.ai, komen deze edgecases snel naar boven omdat web- en mobiele clients vaak hetzelfde endpoint delen. Eén expliciet cursor-contract en één deterministische ordeningsregel houden beide clients consistent.
Voordat je paginatie “klaar” noemt, verifieer het gedrag onder invoegingen, verwijderingen en retries.
next_cursor komt van het laatste geretourneerde resultaatlimit heeft een veilige max en een gedocumenteerde defaultVoor refresh: kies één duidelijke regel: of gebruikers pull-to-refresh doen om nieuwere items bovenaan te halen, of je controleert periodiek “is er iets nieuwers dan mijn eerste item?” en toont een “Nieuwe items” knop. Consistentie zorgt ervoor dat de lijst stabiel in plaats van spookachtig aanvoelt.
Stel je een support-inbox voor die agenten op het web gebruiken, terwijl een manager dezelfde inbox op mobiel bekijkt. De lijst is gesorteerd op nieuwste eerst. Mensen verwachten één ding: als ze vooruit scrollen, springen items niet rond, herhalen ze zich niet en verdwijnen ze niet.
Met offset-paginatie laadt een agent pagina 1 (items 1–20) en scrolt dan naar pagina 2 (offset=20). Terwijl ze lezen, arriveren twee nieuwe berichten bovenaan. Nu wijst offset=20 naar een andere plek dan een seconde geleden. De gebruiker ziet duplicaten of mist berichten.
Met cursor-paginatie vraagt de app om “de volgende 20 items na deze cursor”, waarbij de cursor gebaseerd is op het laatste item dat de gebruiker daadwerkelijk zag (meestal (created_at, id)). Nieuwe berichten kunnen de hele dag binnenkomen, maar de volgende pagina begint nog steeds direct na het laatste bericht dat de gebruiker zag.
Een eenvoudige manier om te testen voordat je uitrolt:
Als je snel prototypeert, kan Koder.ai je helpen het endpoint en clientflows te scaffolden vanuit een chatprompt, en daarna veilig te itereren met Planning Mode plus snapshots en rollback wanneer een paginatie-aanpassing je tests verrast.
Offset-paginatie verwijst naar “sla N rijen over”, dus wanneer nieuwe rijen worden ingevoegd of bestaande rijen worden verwijderd, verschuift het aantal rijen. Dieselvde offset kan ineens naar andere items verwijzen dan een moment eerder, wat duplicaten en gaten veroorzaakt voor gebruikers die aan het scrollen zijn.
Cursor-paginatie gebruikt een bladwijzer die staat voor “de positie na het laatste item dat ik zag”. Het volgende verzoek gaat verder vanaf die positie in een deterministische volgorde, dus invoegingen bovenaan en verwijderingen in het midden verplaatsen je paginagrenzen niet zoals offsets doen.
Gebruik een deterministische sortering met een tie-breaker, meestal (created_at, id) in dezelfde richting. created_at geeft de productvriendelijke volgorde en id maakt elke positie uniek, zodat je geen items herhaalt of overslaat wanneer tijdstempels samenvallen.
Sorteren op updated_at kan ertoe leiden dat items tussen pagina's springen wanneer ze worden bewerkt, wat de verwachting van een “stabiele vooruit-scroll” doorbreekt. Als je een live-weergave van meest recent bijgewerkte items nodig hebt, ontwerp dan de UI om te verversen en accepteer het herschikken in plaats van een stabiele infinite scroll te beloven.
Retourneer een opaque token als next_cursor en laat de client het ongewijzigd terugsturen. Een eenvoudige aanpak is het encoderen van de (created_at, id) van het laatste item als een base64 JSON-blok, maar beschouw de cursor als een ondoorzichtig waarde zodat je intern later kunt wijzigen zonder clients te breken.
Bouw de volgende query op vanuit de cursorwaarden, niet door te proberen “zoek die exacte rij”. Als het laatste item is verwijderd, definieert het opgeslagen (created_at, id) nog steeds een positie, dus je kunt veilig doorgaan met een strengere vergelijking (strictly less than of greater than) in dezelfde volgorde.
Gebruik een strikte vergelijking en een unieke tie-breaker, en neem de cursor altijd van het laatste item dat je daadwerkelijk retourneerde. De meeste herhaalfouten komen voort uit het gebruiken van \u003c= in plaats van \u003c, het weglaten van de tie-breaker, of het genereren van next_cursor vanaf de verkeerde rij.
Kies één duidelijke regel: refresh haalt nieuwere items bovenaan op, terwijl scroll-forward doorgaat naar oudere items vanaf de bestaande cursor. Meng de “refresh-semantiek” niet met dezelfde cursor-flow, anders zien gebruikers herordening en denken ze dat de lijst onbetrouwbaar is.
Een cursor is alleen geldig voor één exacte ordening en één set filters. Als de client sorteeroptie, zoekquery of filters verandert, moet hij een nieuwe paginatiesessie starten zonder cursor en cursors apart opslaan per lijsttoestand.
Cursor-paginatie is ideaal voor sequentieel bladeren maar niet voor stabiele “pagina 20”-sprongen, omdat de dataset kan veranderen. Als je springen nodig hebt, spring naar een anchor zoals “rond deze timestamp” of “startend na dit id”, en pagineer van daar met cursors.