Pagination der ikke dør langsomt

Pagination der ikke dør langsomt

Jeg har engang deployet et “hent alle ordrer” endpoint uden pagination til en lille webshop. Det virkede fint. I tre uger.

Så kom Black Friday. Pludselig sad support og kiggede på en loader i et minut, hver gang de ville se ordrer. Jeg sad og kiggede på database-grafer. De pegede alle sammen stejlt opad.

Det var der, jeg lærte, at pagination ikke er en “nice to have”. Det er forskellen på et API der føles hurtigt, og et API der føles ødelagt.

Historien om et endpoint der voksede op

Scenariet var simpelt: et admin-dashboard skulle vise ordrer fra en orders tabel. Først byggede jeg bare noget i den her stil:

GET /api/orders
SELECT *
FROM orders
ORDER BY created_at DESC;

Det fungerede, så længe der var under 1.000 ordrer. React-listen var glad, databasen var glad, jeg var glad.

Men hver ny ordre gjorde endpointet en lille bitte smule langsommere. Jeg målte ikke. Jeg tænkte “det går nok”.

Det gjorde det ikke.

Det skjulte problem ved “hent alt”

Der sker tre ting, når du ikke har pagination:

  • Din database skal læse flere og flere rækker for hver request.
  • Dit API sender større og større JSON-svar.
  • Din frontend skal rendele længere og længere lister.

Alle tre punkter vokser med mængden af data. Du har en lineær tidsbombe.

Så pagination handler ikke kun om “fancy” API design. Det er din måde at sige: vi henter et lille, kontrolleret udsnit ad gangen.

API-kontrakten: små, forudsigelige bidder

Før vi snakker SQL, giver det mening at være helt konkret om selve API-kontrakten. Hvordan ser request og response ud, når du laver fornuftig pagination og filtering?

Et simpelt offset-baseret API

Det her er den klassiske model, mange starter med:

GET /api/orders?limit=20&offset=40&status=paid&sort=-created_at
  • limit: hvor mange rækker du vil have.
  • offset: hvor mange rækker du springer over.
  • status: et filter.
  • sort: sorteringsfelt, her med minus for faldende.

Svarformatet kunne f.eks. være:

{
  "data": [
    { "id": 123, "status": "paid", "created_at": "2024-03-01T10:00:00Z" },
    { "id": 122, "status": "paid", "created_at": "2024-03-01T09:55:00Z" }
  ],
  "pagination": {
    "limit": 20,
    "offset": 40,
    "total": 842,
    "has_more": true
  }
}

Det er nemt at forstå. Nemt at implementere. Og i små systemer helt fint.

Et cursor-baseret API

Når datamængden vokser, eller data ændrer sig ofte, plejer jeg at skifte til en cursor-model. Requesten kunne se sådan her ud:

GET /api/orders?limit=20&cursor=2024-03-01T10:00:00Z_123&status=paid&sort=-created_at

Og svaret:

{
  "data": [ /* ... */ ],
  "pagination": {
    "limit": 20,
    "next_cursor": "2024-03-01T09:00:00Z_101",
    "has_more": true
  }
}

Her har du ikke nogen offset. Du siger bare “giv mig de næste 20 efter den her cursor”.

Cursoren er typisk bygget af de kolonner, du sorterer på. F.eks. created_at + id. Den del vender jeg tilbage til i SQL-sektionen.

Offset pagination: nem at bygge, dyr i længden

Jeg startede selv med offset pagination, fordi det var det, alle tutorials viste. Og fordi det passede ret pænt med SQLs LIMIT/OFFSET.

Den klassiske offset-query

På SQL-siden ligner det her:

SELECT id, status, created_at
FROM orders
WHERE status = :status
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;

Det er til at leve med, når offset er lille. Men forestil dig at du er nået op på 1.000.000 ordrer, og UI skal til side 5000.

LIMIT 20 OFFSET 100000;

Databasen skal ofte kigge forbi en hel masse rækker, som den smider væk igen, før den når ned til 100.001. række. Det er som at bladre 200 sider frem i en bog ved at læse hver side på vejen.

Hvis du vil se mere om det, har Use The Index, Luke nogle gode illustrationer.

Hvornår offset stadig er ok

Jeg bruger stadig offset nogle steder. Typisk når:

  • Tabellen er lille (f.eks. interne opsætningstabeller).
  • Brugeren ikke skal ret langt ned (2-3 sider maks).
  • Det ikke gør noget, at data flytter sig lidt mellem sider.

Et eksempel kunne være et admin-panel med 200 brugere i alt. Her er offset fint, og det er overkill at bygge cursor.

To klassiske offset-faldgruber

De to ting, der oftest bider folk:

  • Performance ved høje offsets
    Selv med indeks kan store offsets blive tunge. Databasen skal stadig springe en masse rækker over.
  • Inkonsistente sider ved ændringer
    Hvis der indsættes eller slettes rækker mellem to kald, kan side 2 pludselig vise nogle af de samme rækker som side 1, eller springe nogle over.

Så til rigtige lister med brugerdata, ordrer, logs og lignende, hvor antallet bare vokser og vokser, er cursor pagination værd at kigge på.

Cursor pagination: du bladrer, uden at tælle

Min egen “aha”-oplevelse med cursor pagination kom, da jeg sad med en log-tabel, der voksede med flere tusind rækker i timen. Offset pagination begyndte at halte. Cursor løste to ting:

  • Hurtigere queries.
  • Mere stabile resultater, selvom der hele tiden kommer nye rækker.

Idéen i én sætning

I stedet for “giv mig side 5”, siger du “giv mig de næste N rækker efter den her rækkes position i sorteringen”.

Byg en cursor af dine sorteringsfelter

Lad os sige, at vi igen sorterer på created_at DESC, id DESC. For at kunne bladre stabilt, skal cursoren indeholde nok information til at pege på en entydig position i sorteringen.

Jeg bruger tit et simpelt mønster:

{created_at_iso}_{id}

Eksempel:

2024-03-01T10:00:00Z_123

I API-responsen er det bare en streng. På SQL-siden splitter jeg den op i to parametre.

SQL-eksempel: WHERE (created_at, id) < (:cursor_created_at, :cursor_id)

Nu kan vi bygge SQL’en sådan her:

SELECT id, status, created_at
FROM orders
WHERE status = :status
  AND (
    created_at < :cursor_created_at
    OR (created_at = :cursor_created_at AND id < :cursor_id)
  )
ORDER BY created_at DESC, id DESC
LIMIT :limit;

Hvis du bruger en database der understøtter tuple-sammenligning (f.eks. PostgreSQL), kan du skrive det kortere:

SELECT id, status, created_at
FROM orders
WHERE status = :status
  AND (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

Her sker der to vigtige ting:

  • Du bruger ikke OFFSET længere.
  • Databasen kan bruge et indeks på (status, created_at, id) effektivt.

Resultatet er, at den hurtigt kan hoppe hen til cursor-positionen og læse videre derfra.

Request/response-loopet

Hele rejsen ser typisk sådan her ud i frontend:

  1. Første kald uden cursor:
    GET /api/orders?limit=20&status=paid
  2. API svarer med data + next_cursor.
  3. Frontend gemmer next_cursor.
  4. Næste side:
    GET /api/orders?limit=20&status=paid&cursor=<next_cursor>

Hvis du skifter filter (f.eks. status), smider du cursoren væk og starter forfra.

Filtering og sortering uden at åbne for SQL injection

Her er et sted, hvor jeg selv har været fristet til at “snyde” med string-concatenation. Det virker fint indtil en eller anden tastatur-ninja får lov at sende rå tekst direkte ind i din query.

Det er sådan man ender på OWASP som eksempel på, hvad man ikke skal gøre.

Parametre: værdier vs. felt-navne

Der er to typer ting, du typisk modtager som query params:

  • Værdier (f.eks. status=paid, min_total=500).
  • Felt-navne (f.eks. sort=created_at).

De to skal behandles helt forskelligt:

  • Værdier går altid ind som parameteriserede værdier i din query.
    Eksempel med pseudo-kode i backend:
const status = req.query.status; // e.g. "paid"

// OK: parametriseret
const sql = `
  SELECT id, status, created_at
  FROM orders
  WHERE status = $1
  ORDER BY created_at DESC
  LIMIT $2
`;

db.query(sql, [status, limit]);
  • Felt-navne må ALDRIG bare smides ind i en streng fra brugeren.

Her bruger jeg altid en whitelist:

const sortParam = req.query.sort || '-created_at';

const sortMap = {
  'created_at': 'created_at ASC',
  '-created_at': 'created_at DESC',
  'total': 'total ASC',
  '-total': 'total DESC'
};

const orderBy = sortMap[sortParam] || sortMap['-created_at'];

const sql = `
  SELECT id, status, created_at, total
  FROM orders
  WHERE status = $1
  ORDER BY ${orderBy}
  LIMIT $2
`;

Her kan brugeren ikke selv få lov til at skrive kolonnenavnet. De kan kun vælge mellem de nøgler, du har defineret i sortMap.

Filtering med flere felter

Det samme princip bruger jeg til filtre. F.eks.:

GET /api/orders?status=paid&min_total=500&max_total=5000

I backend:

const filters = [];
const params = [];

if (req.query.status) {
  params.push(req.query.status);
  filters.push(`status = $${params.length}`);
}

if (req.query.min_total) {
  params.push(Number(req.query.min_total));
  filters.push(`total >= $${params.length}`);
}

if (req.query.max_total) {
  params.push(Number(req.query.max_total));
  filters.push(`total <= $${params.length}`);
}

const whereClause = filters.length
  ? 'WHERE ' + filters.join(' AND ')
  : '';

const sql = `
  SELECT id, status, created_at, total
  FROM orders
  ${whereClause}
  ORDER BY created_at DESC, id DESC
  LIMIT $${params.length + 1}
`;

params.push(limit);

db.query(sql, params);

Pointen er: du lader brugeren styre værdierne, ikke den rå SQL-streng.

Indekser: uden dem bliver pagination ligegyldig

Du kan designe verdens pæneste API-kontrakt. Hvis din database stadig skal lave fuld tabelscan hver gang, er du ikke nået særlig langt.

Pagination og indeks hænger sammen. Især når du begynder at kombinere WHERE, ORDER BY og cursor.

Hvad skal du indeksere for offset pagination?

Med klassisk offset pagination på created_at DESC og filter på status, vil jeg typisk lave et komposit-indeks sådan her:

CREATE INDEX idx_orders_status_created_at
  ON orders (status, created_at DESC);

Det hjælper databasen med at finde de rækker, der matcher status, i den rigtige rækkefølge.
Men det redder ikke problemet med høje offsets. Det gør bare springet hen til start-positionen lidt hurtigere.

Hvis du vil forstå det mere i dybden, er der en fin intro i artiklen om SQL indeks vs brute force.

Hvad med cursor pagination?

Med cursor pagination, hvor du bruger WHERE på sorteringskolonnerne, kan databasen for alvor bruge sit indeks effektivt.

For sortering på status, created_at DESC, id DESC vil jeg ofte lave:

CREATE INDEX idx_orders_status_created_at_id
  ON orders (status, created_at DESC, id DESC);

Nu svarer dit indeks til den måde, du både filtrerer og sorterer på.

Cursor-queryen:

SELECT id, status, created_at
FROM orders
WHERE status = :status
  AND (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

matcher rigtig pænt med indekset. Databasen kan:

  • Slå hurtigt op på alle rækker med den givne status.
  • Bruge sorteringsrækkefølgen direkte fra indekset.
  • Stoppe så snart den har fundet limit rækker.

Det er her, du ser forskellen på “pagination” og pagination, der faktisk er hurtig.

Typisk fejl: ORDER BY på noget andet end indekset

En ting jeg har dummet mig med: have et indeks på f.eks. (status, created_at), men så sortere alene på created_at eller på en helt anden kolonne i queryen.

Sådan noget her:

-- Indeks
CREATE INDEX idx_orders_status_created_at
  ON orders (status, created_at DESC);

-- Query
SELECT id, status, created_at
FROM orders
WHERE status = :status
ORDER BY total DESC
LIMIT :limit;

Her hjælper indekset meget mindre, fordi ORDER BY ikke matcher. Brug EXPLAIN til at se, om indekset rent faktisk bliver brugt.

Små designvalg før du shipper dit endpoint

Inden jeg kalder et list-endpoint “færdigt”, går jeg typisk lige igennem den samme mentale tjekliste. Det er ikke raketforskning, men det har reddet mig fra en del Black Friday-scenarier efterhånden.

1. Er datamængden sådan, at cursor giver mening?

Jeg spørger mig selv:

  • Forventer jeg tusindvis eller millioner af rækker her over tid?
  • Skal brugeren kunne bladre langt tilbage i historikken?
  • Bliver data typisk ændret eller indsat imens der bladres?

Hvis ja til mindst én af dem, vælger jeg cursor pagination som udgangspunkt.

2. Hvad er det naturlige sorteringsfelt?

Det er fristende at give brugeren fri sortering på alt muligt. Men hver kombination af filtre og sortering, du tillader, koster i kompleksitet og potentielt indekser.

Jeg prøver at vælge 1-2 “naturlige” sorteringer:

  • Ordrer: created_at DESC.
  • Brugere: created_at DESC eller last_login DESC.
  • Logs: typisk created_at DESC.

Hvis nogen får brug for anderledes sortering senere, må jeg tage stilling til, om det er værd at understøtte.

3. Hvilke filtre må bruges sammen med sorteringen?

Her prøver jeg at være ærlig overfor mig selv: hvilke filtre er faktisk nødvendige, og hvilke er “nice to have”, som bare gør queriesne tungere?

Jeg starter ofte med få:

  • status (enum).
  • Et interval (f.eks. dato eller total-beløb).

Og så designer jeg indekset efter de kombinationer, jeg tillader.

4. Har jeg en max limit?

Jeg lader aldrig brugeren bestemme et arbitrært højt limit. Det er en nem måde at lave en DDoS mod sig selv på.

Typisk gør jeg noget i den her stil:

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

let limit = Number(req.query.limit) || DEFAULT_LIMIT;

if (limit > MAX_LIMIT) {
  limit = MAX_LIMIT;
}
if (limit <= 0) {
  limit = DEFAULT_LIMIT;
}

Så kan du ikke bare sende ?limit=1000000 og tvinge API’et til at sende halvdelen af databasen retur.

5. Gemmer jeg nok info i cursoren?

Hvis du bruger cursor pagination, skal du sikre, at cursoren har:

  • Alle felter fra ORDER BY.
  • Et ekstra entydigt felt (typisk id) i sorteringen.
  • Eventuelt også filter-værdier, hvis du vil gøre cursoren helt robust.

I små projekter klarer jeg mig tit med created_at + id. Hvis jeg en dag skifter sorteringen, må jeg invalidate gamle cursors.

6. Har jeg tænkt på API-oplevelsen?

Der er også en brugeroplevelses-side i det her. Et pænt API er beskrevet i artiklen om kaos-API, men kort fortalt prøver jeg at:

  • Bruge konsistente navne: limit, cursor, offset, sort, filter_x.
  • Altid sende has_more eller next_cursor tydeligt i pagination-feltet.
  • Sørge for at fejl ved invalide params er klare (400 med en ordentlig fejlbesked).

Det gør det meget lettere for din fremtidige frontend-kollega. Eller for dig selv om seks måneder.

Offset vs cursor: et lille valg-framework

Hvis jeg skal koge min egen erfaring ned til noget, jeg kan slå op, når jeg er træt en tirsdag aften, ser det nogenlunde sådan her ud:

Når jeg vælger offset

  • Tabellen er lille eller vokser langsomt.
  • Brugeren scroller kun få sider.
  • Data ændrer sig ikke ret meget imens man kigger.
  • Jeg har brug for “spring til side X”-funktionalitet.

Når jeg vælger cursor

  • Tabellen kan blive stor.
  • Data tilføjes/slettes ofte.
  • Brugeren mest bare bladrer frem/tilbage.
  • Jeg vil kunne holde performance nogenlunde stabil, selv når vi har mange rækker.

Det er ikke en lov. Bare en tommelfingerregel jeg selv bruger. Og så prøver jeg at vælge én model per endpoint og holde fast, i stedet for at blande for meget.

Tilbage til Black Friday-bordet

Jeg endte med at redde det første “hent alle ordrer”-endpoint ved at:

  • Tilføje cursor pagination på created_at + id.
  • Lave et komposit-indeks, der matchede WHERE + ORDER BY.
  • Begrænse limit til maks 100.

Det tog en aften, et par kopper kaffe og nogle timer, hvor familien undrede sig over, hvorfor far stirrede så intenst på EXPLAIN-output.

Næste Black Friday var der ingen, der snakkede om det endpoint. Og det er egentlig det bedste tegn på, at paginationen er lykkedes. Ingen lægger mærke til den.

Lidt ligesom med surdej: hvis du passer den løbende, er der ingen, der spørger, hvorfor brødet ikke hæver. De spiser det bare.

Jonas Kirkeby har skrevet kode siden han som teenager forsøgte at lave en helt simpel hjemmeside til sin fars lille vvs-firma – og endte med at sidde oppe hele natten for at få en knap til at skifte farve. Siden da har han lært sig det meste ved at prøve sig frem, kopiere andres eksempler, ødelægge dem og langsomt forstå, hvorfor tingene virker, som de gør.

Til daglig arbejder han slet ikke med IT, men bruger aftener og morgener på små projekter: en lille side til en forening, et simpelt værktøj til at holde styr på familiens madplan eller et Python-script, der rydder op i rodede filer. Det er den slags konkrete hverdags-behov, der har formet hans måde at tænke kodning på – hvad kan jeg bygge nu, som faktisk hjælper mig eller nogen, jeg kender?

På Coding Class deler Jonas de guides, han selv ville ønske, han havde haft: korte, konkrete forløb, hvor du kan se noget på skærmen efter få minutters læsning. Han viser hele vejen fra idé til færdig løsning, inklusive de typiske fejl og små snubletråde på vejen, så du ikke kun får den pæne, polerede version.

Hans mål er, at du som begynder eller let øvet hurtigt får følelsen af: “Det her kan jeg faktisk selv finde ud af” – uanset om du vil bygge din første lille hjemmeside, forstå JavaScript-funktioner eller bruge Python til at automatisere en kedelig opgave.

1 kommentar

comments user
Anne-Lise

Hmm. Artiklen nævner 1.000 ordrer – hvornår begynder frontend at blive mærkbart langsom?

Send kommentar

You May Have Missed