Cursor- versus offset-paginatie voor snelle API's van beheerschermen
Leer cursor- en offset-paginatie met een consistent API-contract voor sortering, filters en totalen, zodat beheerschermen op web en mobiel snel blijven.

Waarom paginatie beheerschermen traag kan doen aanvoelen
Beheerschermen beginnen vaak als een eenvoudige tabel: laad de eerste 25 rijen, voeg een zoekvak toe, klaar. Met een paar honderd records voelt het direct aan. Daarna groeit de dataset, en hetzelfde scherm begint te haperen.
Het probleem zit meestal niet in de UI. Het is wat de API eerst moet doen voordat hij pagina 12 met sortering en filters kan teruggeven. Naarmate de tabel groter wordt, besteedt de backend meer tijd aan het vinden van matches, het tellen ervan en het overslaan van eerdere resultaten. Als elke klik een zwaardere query veroorzaakt, voelt het scherm alsof het nadenkt in plaats van reageert.
Je merkt het vaak op dezelfde plekken: paginawissels worden na verloop van tijd trager, sorteren wordt traag, zoeken voelt inconsistent over pagina's en infinite scroll laadt in bursts (snel, en dan ineens langzaam). In drukke systemen zie je soms zelfs duplicaten of ontbrekende rijen wanneer data verandert tussen verzoeken.
Web- en mobiele UIs duwen paginatie ook in verschillende richtingen. Een web-admin-tabel moedigt het springen naar een specifieke pagina aan en sorteren op veel kolommen. Mobiele schermen gebruiken meestal een oneindige lijst die het volgende blok laadt, en gebruikers verwachten dat elke pull even snel is. Als je API alleen is gebouwd rond paginanummers, lijdt mobiel vaak. Als hij alleen rond next/after is gebouwd, kan een webtabel beperkt aanvoelen.
Het doel is niet alleen om 25 items terug te geven. Het is snelle, voorspelbare paging die stabiel blijft als de data groeit, met regels die hetzelfde werken voor tabellen en infinite lists.
Paginatiebasis waar je UI van afhankelijk is
Paginatie is het splitsen van een lange lijst in kleinere stukken zodat het scherm snel kan laden en renderen. In plaats van de API om elk record te vragen, vraagt de UI om het volgende stuk resultaten.
De belangrijkste controle is de pagina-grootte (vaak limit genoemd). Kleinere pagina's voelen meestal sneller omdat de server minder werk doet en de app minder rijen tekent. Maar pagina's die te klein zijn kunnen schokkerig aanvoelen omdat gebruikers vaker moeten klikken of scrollen. Voor veel admin-tabellen is 25 tot 100 items een praktisch bereik, waarbij mobiel meestal de lagere kant prefereert.
Een stabiele sorteervolgorde is belangrijker dan de meeste teams verwachten. Als de volgorde tussen verzoeken kan veranderen, zien gebruikers duplicaten of ontbrekende rijen tijdens het pagineren. Stabiel sorteren betekent meestal sorteren op een primair veld (zoals created_at) plus een tie-breaker (zoals id). Dit geldt zowel voor offset- als cursor-paginatie.
Vanaf het clientpunt van zicht zou een gepagineerd antwoord de items, een hint voor de volgende pagina (paginanummer of cursor-token) en alleen de tellingen die de UI echt nodig heeft moeten bevatten. Sommige schermen hebben een exacte totaalwaarde nodig voor “1-50 van 12.340”. Andere hebben alleen has_more nodig.
Offset-paginatie: hoe het werkt en waar het pijn doet
Offset-paginatie is de klassieke pagina N-benadering. De client vraagt om een vast aantal rijen en vertelt de API hoeveel rijen eerst overgeslagen moeten worden. Je ziet het als limit en offset, of als page en pageSize die de server in een offset omzet.
Een typisch verzoek ziet er zo uit:
GET /tickets?limit=50&offset=950- “Geef me 50 tickets, sla de eerste 950 over.”
Het past bij veel admin-behoeften: spring naar pagina 20, bekijk oudere records of exporteer grote lijsten in stukjes. Het is ook makkelijk intern te bespreken: “Kijk op pagina 3 en je ziet het.”
Het probleem verschijnt op diepe pagina's. Veel databases moeten nog steeds langs de overgeslagen rijen lopen voordat ze je pagina kunnen teruggeven, vooral wanneer de sortering niet door een strakke index wordt ondersteund. Pagina 1 kan snel zijn, maar pagina 200 kan merkbaar trager worden, en precies dat maakt dat beheerschermen traag voelen wanneer gebruikers scrollen of springen.
Het andere probleem is consistentie wanneer data verandert. Stel je voor dat een supportmanager pagina 5 van tickets opent, gesorteerd op nieuwste eerst. Terwijl hij kijkt komen er nieuwe tickets binnen of worden oudere tickets verwijderd. Invoegingen kunnen items naar voren schuiven (duplicaten over pagina's). Verwijderingen kunnen items naar achteren schuiven (records verdwijnen uit het browsepad van de gebruiker).
Offset-paginatie kan nog prima werken voor kleine tabellen, stabiele datasets of eenmalige exports. Op grote, actieve tabellen ontstaan de randgevallen snel.
Cursor-paginatie: hoe het werkt en waarom het stabiel blijft
Cursor-paginatie gebruikt een cursor als bladwijzer. In plaats van “geef me pagina 7” zegt de client “ga verder na dit exacte item.” De cursor codeert meestal de sorteerwaarden van het laatste item (bijv. created_at en id) zodat de server vanaf de juiste plek kan hervatten.
Het verzoek bevat meestal gewoon:
limit: hoeveel items terug te gevencursor: een ondoorzichtig token uit de vorige respons (vaakaftergenoemd)
De respons geeft items terug plus een nieuwe cursor die naar het einde van die slice wijst. Het praktische verschil is dat cursors de database niet vragen te tellen en rijen over te slaan. Ze vragen om te beginnen vanaf een bekende positie.
Daarom blijft cursor-paginatie snel voor scroll-forward lijsten. Met een goede index kan de database springen naar “items na X” en dan de volgende limit rijen lezen. Bij offsets moet de server vaak steeds meer rijen scannen (of in ieder geval overslaan) naarmate de offset groeit.
Voor UI-gedrag maakt cursor-paginatie “Volgende” natuurlijk: je neemt de teruggegeven cursor en stuurt die bij het volgende verzoek terug. “Vorige” is optioneel en lastiger. Sommige API's ondersteunen een before-cursor, terwijl anderen in omgekeerde volgorde ophalen en de resultaten omdraaien.
Wanneer kiezen voor cursor, offset of een hybride
De keuze begint bij hoe mensen de lijst daadwerkelijk gebruiken.
Cursor-paginatie past het beste wanneer gebruikers voornamelijk vooruitgaan en snelheid het belangrijkst is: activiteitslogs, chats, orders, tickets, audit trails en de meeste mobiele infinite scrolls. Het gedraagt zich ook beter wanneer er nieuwe rijen worden toegevoegd of verwijderd terwijl iemand aan het bladeren is.
Offset-paginatie is zinvol wanneer gebruikers vaak springen: klassieke admin-tabellen met paginanummers, ga-naar-pagina en snelle heen-en-weer navigatie. Het is eenvoudig uit te leggen, maar kan op grote datasets trager worden en minder stabiel wanneer de data onder je verandert.
Een praktische manier om te beslissen:
- Kies cursor wanneer de belangrijkste actie “volgende, volgende, volgende” is.
- Kies offset wanneer “spring naar pagina N” een echte vereiste is.
- Beschouw totalen als optioneel. Nauwkeurige totalen kunnen duur zijn op enorme tabellen.
Hybriden komen vaak voor. Eén aanpak is cursor-gebaseerde next/prev voor snelheid, plus een optionele page-jump-modus voor kleine, gefilterde subsets waar offsets snel blijven. Een andere is cursor-ophaling met paginanummers gebaseerd op een gecachte snapshot, zodat de tabel vertrouwd aanvoelt zonder elk verzoek in zwaar werk te veranderen.
Een consistent API-contract dat werkt op web en mobiel
Admin-UIs voelen sneller wanneer elk lijst-endpoint zich hetzelfde gedraagt. De UI kan veranderen (webtabel met paginanummers, mobiele infinite scroll), maar het API-contract moet gelijk blijven zodat je je niet voor elk scherm opnieuw hoef te verdiepen in paginatieregels.
Een praktisch contract heeft drie onderdelen: rijen, paginatiestatus en optionele totalen. Houd de namen identiek over endpoints heen (tickets, users, orders), zelfs als de onderliggende paginatiemodus verschilt.
Hier is een response-vorm die goed werkt voor zowel web als mobiel:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
Een paar details maken dit makkelijk herbruikbaar:
page.modevertelt de client wat de server doet zonder veldnamen te veranderen.limitis altijd de gevraagde pagina-grootte.nextCursorenprevCursorzijn aanwezig, ook als één van beiden null is.totalsis optioneel. Als het duur is, geef het alleen terug wanneer de client erom vraagt.
Een webtabel kan nog steeds “Pagina 3” tonen door zijn eigen pagina-index bij te houden en de API herhaaldelijk aan te roepen. Een mobiele lijst kan paginanummers negeren en gewoon het volgende blok opvragen.
Als je zowel web als mobiel admin-UIs bouwt in AppMaster, betaalt een stabiel contract als dit zich snel terug. Hetzelfde lijstgedrag kan hergebruikt worden over schermen zonder per endpoint aangepaste paginatie-logica.
Sorteerregels die paginatie stabiel houden
Sorteren is waar paginatie meestal breekt. Als de volgorde tussen verzoeken kan veranderen, zien gebruikers duplicaten, gaten of “ontbrekende” rijen.
Maak sortering een contract, geen suggestie. Publiceer de toegestane sorteervelden en richtingen, en wijs alles anders af. Dat houdt je API voorspelbaar en voorkomt dat clients trage sorts aanvragen die in ontwikkeling onschuldig lijken.
Een stabiele sortering heeft een unieke tie-breaker nodig. Als je sorteert op created_at en twee records hebben dezelfde timestamp, voeg id (of een andere unieke kolom) toe als laatste sorteersleutel. Zonder die tie-breaker mag de database gelijke waarden in willekeurige volgorde teruggeven.
Praktische regels die standhouden:
- Sta alleen sortering toe op geïndexeerde, goed gedefinieerde velden (bijvoorbeeld
created_at,updated_at,status,priority). - Voeg altijd een unieke tie-breaker toe als laatste sleutel (bijvoorbeeld
id ASC). - Definieer een standaard sortering (bijvoorbeeld
created_at DESC, id DESC) en houd die consistent over clients. - Documenteer hoe null-waarden gesorteerd worden (bijvoorbeeld “nulls last” voor datums en nummers).
Sortering stuurt ook het genereren van cursors. Een cursor moet de sorteerwaarden van het laatste item coderen in volgorde, inclusief de tie-breaker, zodat de volgende pagina kan vragen om “na” die tuple. Als de sortering verandert worden oude cursors ongeldig. Behandel sorteerparameters als onderdeel van het cursor-contract.
Filters en totalen zonder het contract te breken
Filters moeten aanvoelen als iets los van paginatie. De UI zegt: “laat me een andere verzameling rijen zien,” en vraagt dan: “blader door die set.” Als je filtervelden in je pagination-token mengt of filters optioneel en ongeverifieerd behandelt, krijg je moeilijk te debuggen gedrag: lege pagina's, duplicaten of een cursor die plots in een andere dataset wijst.
Een simpele regel: filters leven in platte queryparameters (of in de body voor POST), en de cursor is ondoorzichtig en alleen geldig voor exact die filter- plus sortcombinatie. Als de gebruiker een filter wijzigt (status, datumbereik, toegewezen persoon), moet de client de oude cursor laten vallen en vanaf het begin beginnen.
Wees streng over welke filters zijn toegestaan. Het beschermt prestaties en houdt gedrag voorspelbaar:
- Wijs onbekende filtervelden af (negeer ze niet stilletjes).
- Valideer types en bereiken (datums, enums, IDs).
- Beperk brede filters (bijvoorbeeld maximaal 50 IDs in een IN-lijst).
- Pas dezelfde filters toe op data en totalen (geen mismatchende aantallen).
Totalen zijn waar veel API's traag worden. Exacte tellingen kunnen duur zijn op grote tabellen, vooral met meerdere filters. Je hebt doorgaans drie opties: exact, geschat of geen. Exact is prima voor kleine datasets of wanneer gebruikers echt “1-25 van 12.431” nodig hebben. Geschat is vaak genoeg voor admin-schermen. Geen is prima wanneer je alleen “Meer laden” nodig hebt.
Om te voorkomen dat elk verzoek vertraagd wordt, maak totalen optioneel: bereken ze alleen wanneer de client erom vraagt (bijvoorbeeld met een vlag als includeTotal=true), cache ze kort per filterset, of geef totalen alleen op de eerste pagina terug.
Stap voor stap: ontwerp en implementeer het endpoint
Begin met defaults. Een lijst-endpoint heeft een stabiele sorteervolgorde nodig, plus een tie-breaker voor rijen met dezelfde waarde. Bijvoorbeeld: createdAt DESC, id DESC. De tie-breaker (id) voorkomt duplicaten en gaten wanneer nieuwe records worden toegevoegd.
Definieer één request-vorm en houd die saai. Typische parameters zijn limit, cursor (of offset), sort en filters. Als je beide modi ondersteunt, maak ze dan wederzijds exclusief: of de client stuurt cursor, of hij stuurt offset, maar niet allebei.
Houd een consistente response-contract zodat web- en mobiele UIs dezelfde lijstlogica kunnen delen:
items: de pagina met recordsnextCursor: de cursor om de volgende pagina op te halen (ofnull)hasMore: boolean zodat de UI kan beslissen of “Meer laden” getoond wordttotal: totaal aantal overeenkomende records (nulltenzij opgevraagd als tellen duur is)
De implementatie is waar de twee benaderingen uit elkaar lopen.
Offset-queries zijn meestal ORDER BY ... LIMIT ... OFFSET ..., wat op grote tabellen kan vertragen.
Cursor-queries gebruiken seek-voorwaarden op basis van het laatste item: “geef me items waar (createdAt, id) kleiner is dan de laatste (createdAt, id)”. Dat houdt prestaties stabieler omdat de database indexes kan gebruiken.
Voordat je live gaat, voeg beschermmaatregelen toe:
- Cap
limit(bijvoorbeeld max 100) en stel een default in. - Valideer
sorttegen een allowlist. - Valideer filters op type en wijs onbekende keys af.
- Maak
cursorondoorzichtig (codeer de laatste sorteerwaarden) en wijs malformed cursors af. - Beslis hoe
totalwordt opgevraagd.
Test met data die onder je verandert. Maak en verwijder records tussen verzoeken, update velden die sorteren beïnvloeden, en controleer dat je geen duplicaten of ontbrekende rijen ziet.
Voorbeeld: ticketlijst die snel blijft op web en mobiel
Een supportteam opent een admin-scherm om de nieuwste tickets te beoordelen. Ze hebben de lijst direct nodig, zelfs terwijl er nieuwe tickets binnenkomen en agenten oudere bijwerken.
Op het web is de UI een tabel. De standaard sortering is op updated_at (nieuwste eerst), en het team filtert vaak op Open of Pending. Hetzelfde endpoint kan beide acties ondersteunen met een stabiele sortering en een cursor-token.
GET /tickets?status=open&sort=-updated_at&limit=50&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
De respons blijft voorspelbaar voor de 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}
}
Op mobiel voedt hetzelfde endpoint de infinite scroll. De app laadt 20 tickets tegelijk en stuurt dan next_cursor om het volgende batch op te halen. Geen paginanummerlogica en minder verrassingen wanneer records veranderen.
Het belangrijkste is dat de cursor de laatst-gezien positie codeert (bijvoorbeeld updated_at plus id als tie-breaker). Als een ticket wordt bijgewerkt terwijl de agent scrollt, kan het bij de volgende verversing naar boven bewegen, maar het veroorzaakt geen duplicaten of gaten in de reeds gescrollde feed.
Totalen zijn nuttig, maar duur op grote datasets. Een simpele regel is om meta.total alleen terug te geven wanneer de gebruiker een filter toepast (zoals status=open) of er expliciet om vraagt.
Veelvoorkomende fouten die duplicaten, gaten en vertraging veroorzaken
De meeste paginatiebugs zitten niet in de database. Ze komen voort uit kleine API-beslissingen die in testen onschuldig lijken en instorten wanneer data verandert tussen verzoeken.
De meest voorkomende oorzaak van duplicaten (of ontbrekende rijen) is sorteren op een veld dat niet uniek is. Als je sorteert op created_at en twee items dezelfde timestamp hebben, kan de volgorde tussen verzoeken wisselen. De oplossing is simpel: voeg altijd een stabiele tie-breaker toe, meestal de primaire sleutel, en behandel de sortering als een paar zoals (created_at desc, id desc).
Een andere veelvoorkomende fout is clients vrij te laten in pagina-grootte. Eén groot verzoek kan CPU, geheugen en responstijden doen pieken, wat elk beheerscherm vertraagt. Kies een verstandige default en een harde max en geef een fout terug wanneer de client te veel vraagt.
Totalen kunnen ook pijn doen. Het tellen van alle overeenkomende rijen bij elk verzoek kan het traagste deel van het endpoint worden, vooral met filters. Als de UI totalen nodig heeft, haal ze alleen op wanneer gevraagd (of geef een schatting) en voorkom dat list-scrolling op een volledige telling wacht.
Fouten die het vaakst gaten, duplicaten en vertraging veroorzaken:
- Sorteren zonder unieke tie-breaker (onstabiele volgorde)
- Onbeperkte pagina-groottes (server overload)
- Totalen elke keer retourneren (trage queries)
- Offset- en cursorregels door elkaar gebruiken in één endpoint (verwarrend voor clients)
- Dezelfde cursor hergebruiken wanneer filters of sortering veranderen (verkeerde resultaten)
Reset paginatie elke keer dat filters of sortering veranderen. Behandel een nieuw filter als een nieuwe zoekopdracht: clear de cursor/offset en begin bij de eerste pagina.
Snelle checklist voordat je live gaat
Voer dit één keer uit met de API en UI naast elkaar. De meeste problemen ontstaan in het contract tussen het lijstscherm en de server.
- De standaard sortering is stabiel en bevat een unieke tie-breaker (bijvoorbeeld
created_at DESC, id DESC). - Sorteervelden en -richtingen zijn ge-whitelist.
- Er is een maximale pagina-grootte afgedwongen, met een verstandige default.
- Cursor-tokens zijn ondoorzichtig en ongeldig cursorgedrag faalt voorspelbaar.
- Elke filter- of sortwijziging reset de paginatiestatus.
- Totalen-gedrag is expliciet: exact, geschat of weggelaten.
- Hetzelfde contract ondersteunt zowel een tabel als infinite scroll zonder uitzonderingen.
Volgende stappen: standaardiseer je lijsten en houd ze consistent
Kies één admin-lijst die mensen dagelijks gebruiken en maak die je gouden standaard. Een druk gebruikte tabel zoals Tickets, Orders of Users is een goed startpunt. Zodra dat endpoint snel en voorspelbaar aanvoelt, kopieer je hetzelfde contract naar de rest van je beheerschermen.
Schrijf het contract op, zelfs beknopt. Wees expliciet over wat de API accepteert en wat hij teruggeeft zodat het UI-team niet raadt en per endpoint verschillende regels uitvindt.
Een eenvoudige standaard om op elk lijst-endpoint toe te passen:
- Toegestane sorts: exacte veldnamen, richting en een duidelijke default (plus een tie-breaker zoals
id). - Toegestane filters: welke velden gefilterd kunnen worden, waardeformaten en wat er gebeurt bij ongeldige filters.
- Totalen-gedrag: wanneer je een count teruggeeft, wanneer je “onbekend” teruggeeft en wanneer je het overslaat.
- Response-vorm: consistente keys (
items, paging-info, toegepaste sort/filters, totals). - Foutregels: consistente statuscodes en leesbare validatieberichten.
Als je deze admin-schermen bouwt met AppMaster (appmaster.io), helpt het om het paginatiecontract vroeg te standaardiseren. Je kunt hetzelfde lijstgedrag hergebruiken in je webapp en native mobiele apps, en je besteedt minder tijd aan het achtervolgen van paginatie-edgecases later.
FAQ
Offset-paginatie gebruikt limit plus offset (of page/pageSize) om rijen over te slaan, waardoor diepe pagina's vaak trager worden omdat de database meer records moet passeren. Cursor-paginatie gebruikt een after-token gebaseerd op de sorteerwaarden van het laatste item, zodat de database naar een bekende positie kan springen en snel blijft als je doorbladerd.
Omdat pagina 1 meestal goedkoop is, maar pagina 200 de database dwingt om een groot aantal rijen over te slaan voordat er iets teruggegeven wordt. Als je daarnaast sorteert en filtert, groeit de benodigde arbeid, waardoor elke klik meer als een zware query aanvoelt dan een snelle fetch.
Gebruik altijd een stabiele sortering met een unieke tie-breaker, zoals created_at DESC, id DESC of updated_at DESC, id DESC. Zonder tie-breaker kunnen records met dezelfde timestamp van volgorde wisselen tussen verzoeken, wat vaak duplicaten of verdwenen rijen veroorzaakt.
Kies cursor-paginatie voor lijsten waar mensen vooral vooruitgaan en snelheid belangrijk is, zoals activity logs, tickets, orders en mobiele infinite scroll. Omdat de cursor naar een exact laatst-gezien positie verwijst, blijft het gedrag consistent als rijen worden toegevoegd of verwijderd.
Offset-paginatie is zinvol wanneer ‘spring naar pagina N’ een echte UI-eis is en gebruikers vaak heen en weer springen. Het is ook handig voor kleine tabellen of stabiele datasets, waar vertraging op diepe pagina's en verschuivende resultaten onwaarschijnlijk zijn.
Houd één response-vorm over alle endpoints en geef items, paginatiestatus en optionele totalen terug. Een praktisch standaardantwoord bevat items, een page-object (met limit, nextCursor/prevCursor of offset) en een eenvoudige vlag zoals hasNext, zodat zowel webtabellen als mobiele lijsten dezelfde clientlogica kunnen hergebruiken.
Omdat een exacte COUNT(*) op grote, gefilterde datasets het traagste deel van het verzoek kan worden en elke paginawissel vertraagt. Een veiliger standaard is om totalen optioneel te maken, ze alleen te retourneren op verzoek, of has_more terug te geven wanneer de UI alleen “Meer laden” nodig heeft.
Behandel filters als onderdeel van de dataset en maak de cursor alleen geldig voor exact die filter- en sortcombinatie. Als een gebruiker filters of sortering wijzigt, reset de paginatie en begin vanaf de eerste pagina; het hergebruiken van een oude cursor na wijzigingen leidt vaak tot lege of verwarrende resultaten.
Whitelist toegestane sorteervelden en richtingen, en wijs alles anders af zodat clients per ongeluk geen trage of onstabiele orders kunnen opvragen. Geef bij voorkeur op geïndexeerde velden te sorteren en voeg altijd een unieke tie-breaker zoals id toe om de volgorde deterministisch te houden tussen verzoeken.
Dwing een maximale limit af, valideer filters en sorteerparameters, en maak cursor-tokens ondoorzichtig en strikt gevalideerd. Als je admin-schermen bouwt in AppMaster, helpt consistentie in deze regels om dezelfde tabel- en infinite-scroll-gedrag te hergebruiken zonder per scherm custom paginatie-oplossingen.


