# Paginazione a cursore: perché l'offset salta righe

Su 200.000 righe l'offset costa 5,063 ms e il cursore 0,010 ms: ma il problema vero è che l'offset salta una riga quando il dato cambia.

> Pagina: https://ui.eneawork.it/blog/api-paginazione-cursor
> Markdown: https://ui.eneawork.it/blog/api-paginazione-cursor.md

*2026-11-01*

La paginazione a cursore legge la pagina successiva a partire dall'ultima riga vista, non contando le righe da saltare. Su 200.000 righe indicizzate, SQLite 3.51.3 impiega 5,063 ms per una pagina a offset 180.000 e 0,010 ms per la stessa pagina con keyset: la differenza è che l'offset conta, il cursore cerca.

Tabella events con 200.000 righe, indice su (created_at, id), database da 14 MB, SQLite 3.51.3 su Node 24.15.0, 1 novembre 2026: media di 100 letture da 20 righe. Offset 180.000: 5,063 ms; keyset con (created_at, id) > (?, ?): 0,010 ms. Sull'ultima pagina l'offset misura 5,236 ms e il keyset 0,008 ms. Dopo la cancellazione della prima riga, la pagina 2 a offset restituisce gli id da 7 a 11, quella a cursore da 6 a 10. Il catalogo EneUI conta 111.985 componenti, 1.556 blocchi e 12.808 icone da 1.472 librerie, letti da /api/stats lo stesso giorno.

## Perché l'offset invecchia

OFFSET 180000 chiede al database di produrre e scartare 180.000 righe: il costo cresce con la profondità, non con la dimensione della pagina. Peggio, il numero di riga non identifica un punto stabile: se qualcuno cancella una riga prima della pagina corrente, la pagina successiva parte una riga più avanti e ne salta una. Se qualcuno ne inserisce una, la pagina successiva ripete una riga già vista. È il difetto che l'utente nota come contenuto mancante, non come lentezza.

## Il cursore è una chiave, non un numero di pagina

Il cursore contiene l'ultima chiave di ordinamento, per esempio created_at più id come spareggio. La query diventa una ricerca nell'indice: WHERE (created_at, id) > (?, ?) con lo stesso ORDER BY. Perché funzioni, l'ordine della query e l'ordine dell'indice devono coincidere e la chiave deve essere unica: senza il tiebreaker due righe con lo stesso timestamp producono un ordine instabile e righe duplicate o perse.

## I numeri, e cosa dicono

Il rapporto misurato è di circa 500 volte sulla penultima pagina e di oltre 600 sull'ultima, su un database di 14 MB che sta interamente in memoria. Su una tabella più grande e su un disco vero il divario cresce, perché l'offset legge pagine che poi scarta. Il cursore invece tocca solo l'intervallo che serve, e il costo resta quasi costante con la profondità.

EXPLAIN QUERY PLAN SELECT id, created_at FROM events WHERE (created_at, id) > ('2026-01-03T04:00:00.000Z', 180000) ORDER BY created_at, id LIMIT 20; SELECT id, created_at FROM events WHERE (created_at, id) > ('2026-01-03T04:00:00.000Z', 180000) ORDER BY created_at, id LIMIT 20;

## Cosa restituisce l'API

La risposta contiene i dati, un next_cursor opaco e un has_more esplicito. Il client non deve dedurre la fine dalla lunghezza della pagina: una pagina piena può essere l'ultima, e una pagina corta può non esserlo se il filtro ha rimosso righe. Il cursore va validato: se non si decodifica o non corrisponde all'ordinamento richiesto, rispondi 400 invece di restituire una pagina arbitraria. Metti anche un tetto al parametro limit, altrimenti il cursore diventa un modo per chiedere l'intera tabella.

## Il limite: niente salto a pagina 57

Con il cursore non puoi chiedere direttamente la pagina 57, perché non esiste un punto di partenza calcolabile senza conoscere i dati. Per un pannello di amministrazione con poche migliaia di righe e numeri di pagina visibili, l'offset resta la scelta più semplice; per un feed, una lista di ordini o un'esportazione, il cursore è l'unica opzione che regge la crescita. Un cursore firmato evita che il client costruisca chiavi arbitrarie, e un cursore che contiene un timestamp va rifiutato se è nel futuro.

## Da dove partire

Definisci l'ordinamento con un tiebreaker unico e crea l'indice che lo copre.,Restituisci next_cursor e has_more, non un numero di pagina.,Valida il cursore e limita limit: un cursore non decodificabile è un 400, non una pagina vuota.,Proteggi l'endpoint con un rate limit che non dipenda dalla profondità della pagina.,Se il client è Next.js, il passaggio del cursore nei search params è descritto in paginazione con search params; il contratto generale è in progettare un'API.,Per la lista in interfaccia, la vista tabella, i blocchi dashboard e i pulsanti coprono i casi comuni.

## FAQ

### Cosa contiene un cursore opaco?

L'ultima chiave di ordinamento, spesso codificata in base64 e firmata. Il client non deve interpretarlo, solo restituirlo; il server lo decodifica, verifica la firma e ricostruisce la query.

### Posso restituire anche il totale delle righe?

Sì, ma è una seconda query e su tabelle grandi costa quanto l'offset. Se serve un contatore, calcolalo in modo approssimato o mettilo in cache; per il flusso normale bastano next_cursor e has_more.

### Come torno alla pagina precedente?

Conserva il cursore di ogni pagina visitata e riusalo, oppure esegui la stessa query con l'ordine invertito e un cursore iniziale. Non ricalcolare l'offset: è il punto da cui volevi uscire.

### Cosa succede se i dati cambiano mentre pagino?

Il cursore resta stabile sul punto già letto; le righe nuove compaiono solo se rientrano nell'ordinamento dopo quel punto. Le righe cancellate spariscono, e non vengono sostituite da altre, che è il comportamento prevedibile.

### Meglio offset per un pannello di amministrazione?

Sì se le righe sono poche e servono i numeri di pagina. Con poche migliaia di righe i 5 ms diventano irrilevanti e il salto diretto vale più della stabilità; sopra quella soglia il cursore vince su entrambi i fronti.
