Kursorpaginering håller listor stabila när data ändras. Lär dig varför offset-sidindelning brister vid insättningar och borttagningar, och hur man implementerar rena cursors.

Du öppnar en feed, skrollar lite, och allt känns normalt—tills det inte gör det. Du ser samma objekt två gånger. Något du är säker på att du såg saknas. En rad du tänkte trycka på flyttar sig nedåt och du hamnar på fel detaljsida.
Detta är användarupptäckta buggar, även om dina API-responser ser “korrekta” ut var för sig. Vanliga symptom är lätta att känna igen:
Det här blir värre på mobil. Folk pausar, byter app, tappar uppkoppling och fortsätter senare. Under tiden kommer nya objekt, gamla tas bort och vissa redigeras. Om din app fortsätter fråga efter “sida 3” med ett offset, kan sidgränserna skifta medan användaren är mitt i en scroll. Resultatet blir en feed som känns instabil och opålitlig.
Målet är enkelt: när en användare börjar skrolla framåt ska listan bete sig som en snapshot. Nya objekt kan finnas, men de ska inte omordna det användaren redan bläddrar igenom. Användaren ska få en jämn, förutsägbar sekvens.
Ingen paginering är perfekt. Verkliga system har samtidiga skrivningar, redigeringar och flera sorteringsalternativ. Men kursorpaginering är vanligtvis säkrare än offset-paginering eftersom den paginerar från en specifik position i en stabil ordning, istället för från ett rörligt radantal.
Offset-paginering är sättet “hoppa över N, ta M” för att bläddra i en lista. Du berättar för API:t hur många objekt som ska hoppas över (offset) och hur många som ska returneras (limit). Med limit=20 får du 20 objekt per sida.
Konceptuellt:
GET /items?limit=20&offset=0 (första sidan)GET /items?limit=20&offset=20 (andra sidan)GET /items?limit=20&offset=40 (tredje sidan)Svaret brukar innehålla objekten plus tillräcklig info för att begära nästa sida.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Det är populärt eftersom det kartlägger fint mot tabeller, admin-listor, sökresultat och enkla feeds. Det är också enkelt att implementera i SQL med LIMIT och OFFSET.
Men fångsten är den dolda antagandet: datasetet står stilla medan användaren bläddrar. I verkliga appar rör sig listor. Nya rader sätts in, rader tas bort och sorteringsnycklar ändras. Det är där “mysteriebuggarna” börjar.
Offset-paginering antar att listan är oförändrad mellan förfrågningar. Men verkliga listor flyttar sig. När listan skiftar pekar en offset som “hoppa över 20” inte längre på samma objekt.
Föreställ dig en feed sorterad på created_at desc (nyast först), sidstorlek 3.
Du laddar sida 1 med offset=0, limit=3 och får [A, B, C].
Nu skapas ett nytt objekt X som hamnar överst. Listan är nu [X, A, B, C, D, E, F, ...]. Du laddar sida 2 med offset=3, limit=3. Servern hoppar över [X, A, B] och returnerar [C, D, E].
Du såg precis C igen (en duplikat), och senare kommer du missa ett objekt eftersom allt skiftade ner.
Borttagningar orsakar motsatt fel. Börja med [A, B, C, D, E, F, ...]. Du laddar sida 1 och ser [A, B, C]. Innan sida 2 raderas B, så listan blir [A, C, D, E, F, ...]. Sida 2 med offset=3 hoppar över [A, C, D] och returnerar [E, F, G]. D blir en lucka du aldrig hämtar.
I nyast-först-feeds händer insättningar överst, vilket är precis det som flyttar varje senare offset.
En “stabil lista” är vad användare förväntar sig: när de skrollar framåt hoppar inte objekt omkring, upprepas eller försvinner utan tydlig anledning. Det handlar mindre om att frysa tiden och mer om att göra paginering förutsägbar.
Två idéer blandas ofta ihop:
created_at med en tie-breaker som id) så två förfrågningar med samma indata returnerar samma ordning.Uppdatering och scroll-framåt är olika handlingar. Uppdatering betyder “visa mig vad som är nytt just nu”, så toppen kan ändras. Scroll-framåt betyder “fortsätt där jag var”, så du bör inte se upprepningar eller oväntade luckor orsakade av förskjutna sidgränser.
En enkel regel som förhindrar de flesta pagineringsbuggar: att skrolla framåt ska aldrig visa upprepningar.
Kursorpaginering rör sig genom en lista med ett bokmärke istället för ett sidnummer. Istället för “ge mig sida 3” säger klienten “fortsätt härifrån”.
Kontraktet är rakt på sak:
Det tål insättningar och borttagningar bättre eftersom cursorn är förankrad i en position i den sorterade listan, inte i ett radantal.
Det icke-förhandlingsbara kravet är en deterministisk sorteringsordning. Du behöver en stabil ordningsregel och en konsekvent tie-breaker, annars är cursorn inte ett pålitligt bokmärke.
Börja med att välja en sorteringsordning som matchar hur människor läser listan. Feeds, meddelanden och aktivitetsloggar är oftast nyast först. Historik som fakturor och revisionsloggar är ofta enklare äldst först.
En cursor måste unikt identifiera en position i den ordningen. Om två objekt kan dela samma cursor-värde kommer du så småningom få dupliceringar eller luckor.
Vanliga val och vad du bör se upp för:
created_at endast: enkelt, men osäkert om många rader delar samma tidsstämpel.id endast: säkert om ID är monotont, men det kanske inte matchar produktordningen du vill ha.created_at + id: vanligtvis den bästa mixen (tidsstämpel för produktordning, id som tie-breaker).updated_at som primär sortering: riskabelt för oändlig rullning eftersom redigeringar kan flytta objekt mellan sidor.Om du erbjuder flera sorteringsalternativ, behandla varje sortläge som en separat lista med egna cursor-regler. En cursor är bara meningsfull för en exakt ordning.
Du kan hålla API-ytan liten: två indata, två utdata.
Skicka en limit (hur många objekt du vill ha) och en valfri cursor (var du ska fortsätta ifrån). Om cursorn saknas returnerar servern första sidan.
Exempel på förfrågan:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Returnera objekten och en next_cursor. Om det inte finns någon nästa sida, returnera next_cursor: null. Klienter bör behandla cursorn som en token, inte något att redigera.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Server-logiken i klarspråk: sortera i en stabil ordning, filtrera med hjälp av cursorn, och applicera sedan limit.
Om du sorterar nyast först med (created_at DESC, id DESC), avkoda cursorn till (created_at, id), sedan hämta rader där (created_at, id) är strikt mindre än cursor-paret, applicera samma ordning och ta limit rader.
Du kan koda cursorn som en base64-kodad JSON-klump (enkelt) eller som en signerad/krypterad token (mer arbete). Opaka cursors är säkrare eftersom de låter dig ändra intern representation senare utan att bryta klienter.
Sätt också vettiga standarder: en rimlig mobilstandard (ofta 20–30), en webbstandard (ofta 50) och en hård server-max så att en buggig klient inte kan begära 10 000 rader.
En stabil feed handlar mest om ett löfte: när användaren börjar skrolla framåt ska de objekt de inte sett ännu inte studsa runt för att någon annan skapade, raderade eller redigerade poster.
Med kursorpaginering är insättningar enklast. Nya poster ska visas vid uppdatering, inte mitt i redan inlästa sidor. Om du sorterar på created_at DESC, id DESC lever nya objekt naturligt före första sidan, så din befintliga cursor fortsätter in i äldre objekt.
Borttagningar ska inte omordna listan. Om ett objekt raderas returneras det helt enkelt inte när du skulle ha hämtat det. Om du behöver hålla sidstorlekar konsekventa, fortsätt hämta tills du samlat limit synliga objekt.
Redigeringar är där team ibland oavsiktligt återinför buggar. Nyckelfrågan är: kan en redigering ändra sorteringspositionen?
Snapshot-stil är oftast bäst för skrollande listor: paginera efter en oföränderlig nyckel som created_at. Redigeringar kan ändra innehållet, men objektet hoppar inte till en ny position.
Live-feed-beteende sorterar efter något som edited_at. Det kan orsaka hopp (ett gammalt objekt redigeras och flyttas högt upp). Om du väljer detta, behandla listan som ständigt föränderlig och designa UX för uppdatering.
Gör inte cursorn beroende av “hitta exakt den här raden”. Koda positionen i stället, t.ex. {created_at, id} för det sista returnerade objektet. Nästa fråga bygger på värden, inte på radens existens:
WHERE (created_at, id) < (:created_at, :id)id) för att undvika dupliceringarFramåt-paginering är den enkla delen. De mer knepiga UX-frågorna är bakåt-paginering, uppdatering och slumpmässig åtkomst.
För bakåt-paginering brukar två tillvägagångssätt fungera:
next_cursor för äldre objekt och prev_cursor för nyare) samtidigt som du behåller en på-skärmen sorteringsordning.Slumpmässiga hopp är svårare med cursors eftersom “sida 20” inte har en stabil betydelse när listan förändras. Om du verkligen behöver hoppa, hoppa till en ankare som “runt denna tidsstämpel” eller “börja från detta meddelande-id”, inte ett sidindex.
På mobil spelar caching roll. Spara cursors per listtillstånd (fråga + filter + sort) och behandla varje flik/vy som sin egen lista. Det förhindrar att “byt flik och allt rubbas”-beteende.
De flesta problem med kursorpaginering handlar inte om databasen. De kommer från små inkonsekvenser mellan förfrågningar som bara visar sig under verklig trafik.
De största bovarna:
created_at ensam) så att likvärdiga värden ger dupliceringar eller saknade objekt.next_cursor som inte matchar det sista faktiska returnerade objektet.Om du bygger appar på plattformar som Koder.ai visar dessa edge-cases sig snabbt eftersom webb- och mobilklienter ofta delar samma endpoint. Ett explicit cursor-kontrakt och en deterministisk ordningsregel håller båda klienterna konsekventa.
Innan du kallar paginering “klar”, verifiera beteendet under insättningar, borttagningar och retries.
next_cursor tas från den sista returnerade radenlimit har en säker max och en dokumenterad standardFör uppdatering, välj en klar regel: antingen drar användaren för att uppdatera för att hämta nyare objekt överst, eller så kontrollerar du periodiskt “något nyare än mitt första objekt?” och visar en “Nya objekt”-knapp. Konsekvens är det som får listan att kännas stabil istället för hemsökt.
Föreställ dig en support-inkorg som agenter använder på webben, medan en chef granskar samma inkorg på mobil. Listan är sorterad nyast först. Folk förväntar sig en sak: när de skrollar framåt hoppar inte objekt, upprepas eller försvinner.
Med offset-paginering laddar en agent sida 1 (objekt 1–20), sedan sida 2 (offset=20). Medan de läser kommer två nya meddelanden överst. Nu pekar offset=20 på en annan plats än tidigare. Användaren ser dupliceringar eller missar meddelanden.
Med kursorpaginering frågar appen efter “nästa 20 objekt efter denna cursor”, där cursorn baseras på det sista objektet användaren faktiskt såg (vanligtvis (created_at, id)). Nya meddelanden kan komma hela dagen, men nästa sida börjar fortfarande precis efter det sista meddelandet användaren såg.
Ett enkelt sätt att testa innan lansering:
Om du prototypar snabbt kan Koder.ai hjälpa dig bygga endpoint och klientflöden från en chatt-prompt, och sedan iterera säkert med Planning Mode plus snapshots och rollback när en pagineringsändring överraskar dig i test.
Offset-paginering pekar på “hoppa över N rader”, så när nya rader läggs till eller gamla rader tas bort skiftar radantalet. Samma offset kan plötsligt referera till andra objekt än tidigare, vilket skapar dupliceringar och luckor för användare mitt i en scroll.
Kursorpaginering använder ett bokmärke som representerar “positionen efter det sista objektet jag såg”. Nästa förfrågan fortsätter från den positionen i en deterministisk ordning, så insättningar överst och borttagningar i mitten flyttar inte din sidgräns på samma sätt som offsets gör.
Använd en deterministisk sortering med en tie-breaker, oftast (created_at, id) i samma riktning. created_at ger en produktvänlig ordning och id gör varje position unik så att du inte upprepar eller hoppar över objekt när tidsstämplar kolliderar.
Sortering efter updated_at kan få objekt att hoppa mellan sidor när de redigeras, vilket bryter förväntningen om “stabil rullning framåt”. Om du behöver en livevy för "senast uppdaterat", designa UI:t för uppdatering och acceptera omordning istället för att lova en stabil oändlig rullning.
Returnera en opak token som next_cursor och låt klienten skicka tillbaka den oförändrad. Ett enkelt sätt är att koda det sista objektets (created_at, id) i en base64-kodad JSON-klump, men det viktiga är att behandla det som ett opakt värde så att du kan ändra intern representation senare.
Bygg nästa fråga från cursor-värdena, inte från “hitta exakt den här raden”. Om det sista objektet raderades definierar de sparade (created_at, id) fortfarande en position, så du kan säkert fortsätta med en strikt "less than" (eller "greater than")-filtrering i samma ordning.
Använd en strikt jämförelse och en unik tie-breaker, och ta alltid cursorn från det sista objektet du faktiskt returnerade. De flesta upprepningsbuggar kommer från att använda <= istället för <, utelämna tie-breakern eller generera next_cursor från fel rad.
Välj en tydlig regel: uppdatering laddar nyare objekt överst, medan scroll-framåt fortsätter till äldre objekt från den befintliga cursorn. Blanda inte “uppdateringssemantik” i samma cursor-flöde, annars kommer användare att se omordning och uppfatta listan som opålitlig.
En cursor är bara giltig för en exakt ordning och en uppsättning filter. Om klienten ändrar sorteringsläge, sökfråga eller filter måste den starta en ny pagineringssession utan cursor och lagra cursors separat per listtillstånd.
Kursorpaginering är utmärkt för sekventiell bläddring men inte för stabila “sida 20”-hopp eftersom datasetet kan förändras. Om du behöver hoppa, hoppa till en ankare som “runt denna tidsstämpel” eller “börjar efter detta id” och paginera med cursors därifrån.
De vanligaste problemen dyker inte i databasen utan i små inkonsekvenser mellan förfrågningar som bara syns under verklig trafik. Kontrollera att du alltid använder en unik cursor, att next_cursor kommer från sista returnerade rad, att filter och sort inte ändras, och att du inte blandar offset och cursor i samma endpoint.