Jeg opdagede først pagination-fejlene, da brugerne begyndte at mangle data
Det er lidt som at bygge en bogreol: du kan godt bare stille bøgerne tilfældigt ind, indtil du skal finde den samme bog to gange i træk, eller opdager at der mangler en midt i rækken. Pagination i et API føles også nemt, lige indtil nogen scroller, går tilbage og pludselig får dubletter eller huller.
I den her artikel fokuserer vi på tre modeller: offset, cursor og keyset pagination. Målet er, at du kan vælge en model til dit API med åbne øjne, og teste den, så du ikke opdager fejlene i produktion.
Problemet pagination egentlig skal løse
Pagination handler om to ting: performance og konsistens.
Performance: du vil ikke sende 50.000 rækker i ét svar. Database og netværk dør langsomt, og brugerens browser dør hurtigt.
Konsistens: en bruger, der bladrer gennem en liste, forventer ikke at få den samme række to gange, eller miste noget midt i forløbet, bare fordi andre brugere opretter eller sletter imens.
De tre klassiske krav er:
- Hver side har en afgrænset mængde data (limit)
- Klient kan hoppe videre (og nogle gange tilbage) forudsigeligt
- Resultaterne er så stabile som muligt, selvom data ændrer sig
Offset, cursor og keyset løser de her krav forskelligt. Forskellen er især tydelig, når data ikke står stille.
Offset pagination – nem at bygge, følsom for ændringer
Offset er den klassiske: ?page=3&limit=20 eller ?offset=40&limit=20.
SQL ser typisk sådan her ud:
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;
API-request:
GET /api/posts?limit=20&offset=40
Fordele:
- Nemt at forstå for både dig og frontend
- Nemt at debugge: du kan selv kalde side 5 direkte
- Fungerer fint på små tabeller og admin-interfaces
Problemerne starter når:
- Tabellen bliver stor (mange tusind rækker og op)
- Brugerne opretter/sletter imens andre bladrer
- Sorteringen ikke er stabil (mere om det senere)
Hvor offset stadig er ok
Jeg vælger typisk offset når:
- Der maksimalt er nogle få tusind rækker i tabellen
- Det er et internt admin-UI, hvor små inkonsistenser er acceptable
- Brugeren kan søge og filtrere sig mere præcist i stedet for at bladre 100 sider
Her er performance-kravene ofte lavere, og det vigtigste er at komme hurtigt i mål med noget, der er til at overskue.
Offset og performance: hvorfor det bliver tungt
Databasen skal stadig tælle sig frem til OFFSET 100000, selvom den kun returnerer 20 rækker. Med mange rækker kan det koste dyrt.
En løsning er at have et passende indeks til din ORDER BY:
CREATE INDEX idx_posts_created_at_desc
ON posts (created_at DESC);
Det hjælper, men offset bliver stadig tungt ved meget store tabeller. Tænk på det som at bladre til side 5000 i en bog ved at læse hver eneste side på vej derhen.
Offset og konsistens: dubletter og huller
Forestil dig:
- Bruger A henter side 1 (offset 0, limit 20)
- Bruger B opretter 3 nye poster, som havner forrest
- Bruger A henter side 2 (offset 20, limit 20)
Side 2 er nu forskudt, fordi 3 rækker er blevet presset ind foran. Bruger A får muligvis ikke de poster, der lå lige efter side 1, da de nu er skubbet ned.
Det modsatte kan ske ved sletning: du kan få dubletter, fordi noget forsvinder foran dig, så nye rækker ryger frem til en tidligere offset.
Det er her cursor/keyset modeller vinder.
Cursor og keyset pagination – du bladrer efter værdier, ikke tal
Cursor og keyset pagination bygger på samme idé: i stedet for at sige “giv mig side 3” siger du “giv mig de næste 20 efter det her element”.
Du bruger altså værdier (typisk fra den sidste række på forrige side) som reference.
Mental model: fortsæt fra sidste række
Hvis du sorterer efter created_at DESC, så kan du gemme created_at fra sidste række på siden og bruge den som cursor:
SELECT *
FROM posts
WHERE created_at < :cursor_created_at
ORDER BY created_at DESC
LIMIT :limit;
Her betyder cursoren “fortsæt efter det her tidspunkt”. Ingen offset, kun “større end” eller “mindre end” sammenligninger.
Keyset pagination: når cursoren består af flere felter
Hvis flere rækker kan have samme created_at, skal du have en tie-breaker (mere om det under stabil sortering). Et klassisk mønster er at kombinere created_at og id i cursoren.
SQL kan se sådan ud i PostgreSQL:
SELECT *
FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;
Her er cursoren egentlig et sæt værdier, men du sender den typisk som én kodet streng til klienten.
Hvornår cursor/keyset vinder over offset
Jeg vælger cursor/keyset når:
- Listen kan blive meget stor (hundredtusinder eller millioner af rækker)
- Brugeren bladrer meget (infinite scroll, feed, historik)
- Det er vigtigt at undgå dubletter og huller
Performancemæssigt er det databasen, der vinder her: den kan bruge indeks til at slå op “start her” og scanne fremad, uden at tælle sig gennem alt det før.
Stabil sortering – uden det er alle modeller skrøbelige
Pagination uden stabil sortering er som at sortere bøger efter farve én dag og højde den næste. Du kan ikke forvente, at samme side viser det samme over tid.
Stabil sortering betyder: hver række har en entydig position i rækkefølgen. Ingen rækker er “lige gode” uden flere felter.
ORDER BY + tie-breaker
Et klassisk mønster:
SELECT *
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT :limit;
created_at bestemmer overordnet rækkefølgen, id afgør rækkefølgen for rækker med samme created_at.
Det giver to gevinster:
- Offset bliver en smule mindre ustabil
- Cursor/keyset kan bruge begge felter og altid fortsætte et entydigt sted fra
Typisk fejl: ORDER BY på et felt uden indeks, eller uden tie-breaker. Så står databasen friere til at beslutte rækkefølgen, og du får underlige effekter.
API-design – hvordan ser responses ud i praksis?
Lad os kigge på konkrete API-kontrakter. Jeg holder dem simple, så du kan kopiere mønstrene.
Offset-baseret API-response
{
"items": [ /* array af posts */ ],
"limit": 20,
"offset": 40,
"total": 1234,
"hasMore": true
}
Her er total valgfri. Den kan være dyr at beregne på meget store tabeller, så overvej om din klient egentlig har brug for den.
Request:
GET /api/posts?limit=20&offset=40
Cursor-baseret API-response
Her bruger vi en opaque (uglæselig) cursor, som klienten bare sender videre uden at kende indholdet.
{
"items": [ /* array af posts */ ],
"limit": 20,
"nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNFQxMTo0NToyMiIsImlkIjoiMTIzNCJ9",
"hasMore": true
}
Cursoren kan være f.eks. en base64-encodet JSON med created_at og id. Klienten skal ikke pille i den.
Request til næste side:
GET /api/posts?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNC0wMy0xNFQxMTo0NToyMiIsImlkIjoiMTIzNCJ9
Det er samme mønster du ser i mange større APIer, f.eks. Slack og GitHub.
Keyset i APIet – samme idé, lidt mere eksplicit
Hvis du ikke gider encode cursoren, kan du også gøre den mere eksplicit:
GET /api/posts?limit=20&created_before=2024-03-14T11:45:22Z&id_lt=1234
Og så returnere værdierne igen i response:
{
"items": [ /* ... */ ],
"limit": 20,
"next": {
"created_before": "2024-03-14T10:02:11Z",
"id_lt": 1010
},
"hasMore": true
}
Fordel: nemt at debugge i browseren. Ulempe: lidt mere støj i URLen og APIet.
Edge cases – hvad sker der når data ændrer sig mellem kald?
Det er her forskellen på cursor pagination vs offset bare står og lyser.
Nye rækker ind imellem siderne
Scenario:
- Bruger A henter første side
- Bruger B opretter 5 nye rækker, der ligger “foran”
- Bruger A henter næste side
Med offset:
- Anden side springer potentielt over 5 rækker, fordi de er skubbet til en tidligere offset
Med cursor/keyset:
- Anden side fortsætter fra sidste synlige række, uanset hvad der er kommet til foran
Brugeren vil typisk ikke savne de helt nye rækker, for de står “før” der hvor vedkommende var nået til. Det føles naturligt.
Slettede rækker midt i forløbet
Scenario:
- Bruger A henter første side
- En række midt i resultatet bliver slettet
- Bruger A henter næste side
Offset:
- Du risikerer dubletter, fordi offset nu peger tidligere i rækken
Cursor/keyset:
- Du fortsætter stadig fra sidste synlige række, den slettede bliver bare aldrig vist
Cursor/keyset håndterer altså både inserts og deletes mere roligt.
Opdaterede rækker, der ændrer sorteringsfelt
Hvis du sorterer efter et felt, som brugeren kan ændre (f.eks. titel eller rating), kan rækkens position hoppe rundt midt i et forløb.
To løsninger jeg ofte ender på:
- Sortér efter et felt, der ikke ændrer sig (f.eks.
created_at) - Accepter, at rækken kan dukke op to gange i et forløb og dokumentér det
Hvis du vil nørde sortering og indekser mere, så er Use the Index, Luke stadig noget af det bedste at læse.
Indeksering – uden de rigtige indeks er pagination bare langsom
Pagination-performance er i høj grad et spørgsmål om, om databasen kan gøre arbejdet med et indeks i hånden i stedet for full table scan.
Indeks til offset pagination
Du skal have et indeks, der matcher din ORDER BY. Eksempel:
SELECT *
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset;
CREATE INDEX idx_posts_created_at_id_desc
ON posts (created_at DESC, id DESC);
Det hjælper både offset og cursor, så det er et fint sted at starte.
Typisk fejl: have et indeks på id, men sortere efter created_at, eller sortere efter noget beregnet (f.eks. LOWER(title)) uden et tilsvarende indeks.
Indeks til cursor/keyset pagination
Med cursor/keyset er mønsteret ofte:
SELECT *
FROM posts
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;
Her spiller samme indeks ind:
CREATE INDEX idx_posts_created_at_id_desc
ON posts (created_at DESC, id DESC);
Hvis du bruger andre sorteringer, så lav tilsvarende sammensatte indeks. Databasedokumentationen (f.eks. PostgreSQLs kapitel om indeks) er guld værd at have åben, mens du designer det.
Testplan – 6 cases der afslører pagination-bugs hurtigt
Du kan fange ret mange pagination-fejl med nogle få simple, automatiserede tests. Tænk dem som små boulders du lige skal bestige én for én.
1. Ingen dubletter på tværs af sider
Hent f.eks. 5 sider i træk med samme parametre. Saml alle ider og tjek at der ikke er nogen, der går igen.
const allIds = new Set();
for (const item of allItemsFrom5Pages) {
if (allIds.has(item.id)) throw new Error("Duplicate id " + item.id);
allIds.add(item.id);
}
2. Ingen huller ved statisk datasæt
Frys data (ingen inserts/deletes) og hent alle sider. Sammenlign rækkefølgen med en direkte query i databasen uden pagination.
De to lister skal være identiske, både hvad angår antal og rækkefølge.
3. Stabil rækkefølge ved små ændringer
Lav en test der:
- Henter side 1
- Indsætter nogle nye rækker, der hører til “foran” side 1
- Henter side 2
Med cursor/keyset skal side 2 stadig starte der, hvor side 1 slap, selvom der er kommet noget nyt foran.
4. Sletning midt i forløbet
Test:
- Hent side 1
- Slet en række fra resultatet på side 1
- Hent side 2
Bekræft at der ikke kommer dubletter i side 2, og at du stadig får lige så mange rækker retur som forventet (så vidt muligt).
5. Sorteringsfelt ændres
Hvis dit sorteringsfelt kan ændres, så test at:
- En opdateret række ikke knækker din pagination
- Klienten kan tåle at rækken måske flytter sig lidt
For eksempel: opdater en posts created_at eller sorteringsfelt mellem to kald, og se om paginationen stadig holder sig nogenlunde fornuftig.
6. Performance ved høj offset eller dybe cursors
Kør load-tests mod:
- Offset pagination med høj offset (f.eks. 100.000)
- Cursor/keyset pagination med tilsvarende dybde
Mål responstid og CPU. Det giver en meget konkret oplevelse af forskellen, og kan være det, der afgør valget i et rigtigt projekt.
Beslutningstabel – hvornår vælger du hvad?
Nu kommer den del, jeg selv savnede første gang jeg stod med valget: en direkte sammenligning.
Use cases og anbefalinger
- Lille admin-liste med få hundrede rækker
Vælg: offset
Begrundelse: nemt at bygge, nemt for frontend, performance ok. - Stort feed (f.eks. aktiviteter, notifikationer)
Vælg: cursor eller keyset
Begrundelse: brugeren scroller meget, data ændrer sig konstant, du vil undgå dubletter og huller. - Rapporter / eksport-funktion
Vælg: cursor/keyset eller batch i baggrunden
Begrundelse: store datamængder, fokus på performance og stabilitet. - Public API med ukendt belastning
Vælg: cursor som primær, evt. offset som “simple” mode til små lister
Begrundelse: bedre performance og kontrol på lang sigt. Dokumentér kontrakten tydeligt, f.eks. med eksempler som i andre backend-artikler på Coding Class. - Søgeresultater med stærk filtrering
Vælg: ofte offset i starten, men overvej cursor hvis du kan definere et stabilt sorteringsfelt
Begrundelse: mange filtre kan gøre keyset mere besværligt, men det kan stadig betale sig ved store datamængder.
Små beslutningsspørgsmål før du vælger
Du kan bruge de her spørgsmål som tjekliste, lidt som i artiklen om offset vs cursor (hvis du har læst den, er du allerede foran):
- Hvor mange rækker kan der maksimalt være?
- Hvor ofte ændrer listen sig, mens brugeren kigger på den?
- Er der et naturligt, stabilt sorteringsfelt, der ikke ændrer sig?
- Skal brugeren kunne hoppe til “side 10” direkte, eller er “næste/forrige” nok?
- Er det her projekt et øveprojekt, hvor enkel implementation vægter højere end perfekt konsistens?
Jo flere “stor”, “ofte” og “vigtigt” du svarer, jo mere giver cursor/keyset mening. Jo mere “lille”, “sjældent” og “det er bare admin” du svarer, jo mere ok er offset.
Sådan kan du bygge videre herfra
Hvis du vil prøve det af i et lille projekt, så er et godt næste skridt at tage en simpel liste-endpoint og implementere både offset og cursor side om side. Mål performance forskellen, og prøv bevidst at lave de edge cases jeg beskrev.
Når du har gjort det én gang, er pagination ikke længere en abstrakt “best practice”-ting; det er bare endnu et mønster i værktøjskassen. Lidt ligesom første gang man opdager, at man kan automatisere kedelige opgaver med et lille Python-script.
Det vigtigste råd at tage med: vælg én pagination-model pr. endpoint, design en stabil sortering, og skriv et par tests der angriber de grimme cases, før dine brugere gør det.









Send kommentar
Du skal være logget ind for at skrive en kommentar.