Cursor Pagination è un metodo di caricamento paginato dei dati che utilizza un cursore univoco per navigare attraverso un insieme ordinato di record. Secondo la Specifica GraphQL (2025), la paginazione basata su cursore è lo standard raccomandato per le API che lavorano con dati dinamici. Cursor pagination elimina i principali svantaggi dell’approccio Offset: instabilità durante le inserzioni e degrado delle prestazioni su grandi offset.
Punti chiave
Cursor Pagination è un metodo di caricamento paginato in cui il server restituisce insieme ai dati un puntatore speciale — un cursore. Il client utilizza questo cursore nella richiesta successiva per ottenere il lotto successivo di record. Il cursore è un identificatore univoco dell’ultimo elemento della pagina corrente.
A differenza della paginazione Offset, dove il client dice “dammi la pagina 5 con 20 record,” la paginazione tramite cursore funziona diversamente: “dammi 20 record dopo il record con ID = 83.” Il server esegue una query con WHERE id > 83 e LIMIT 20. Questo approccio garantisce che ogni record cada esattamente in una pagina indipendentemente dalle inserzioni.
Il concetto di paginazione tramite cursore ha ottenuto ampia adozione grazie alla specifica Relay Connection (GraphQL), che ha reso la paginazione basata su cursore lo standard per le API moderne. Relay definisce il formato di risposta: edges (array di record con cursori), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
La paginazione tramite cursore non è una tecnica nuova — veniva utilizzata nei database molto prima del web. In SQL si chiama keyset pagination o seek method. Il metodo è diventato popolare nelle API dopo la pubblicazione della specifica Relay nel 2015, che ha formalizzato il formato del cursore come stringa codificata in base64 per uniformità sul trasporto HTTP.
Il principio base della paginazione tramite cursore è che la query utilizza una condizione WHERE su un campo indicizzato per il posizionamento, non un offset. Per la direzione in avanti si usa WHERE id > last_id; per la direzione indietro, WHERE id < first_id. L’indice B-tree trova il primo record dopo il cursore in O(log n), fornendo un tempo di risposta stabile.
-- Ottieni 20 record dopo il cursore '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Ottieni 20 record PRIMA del cursore '83' (indietro)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Un cursore può essere semplice (un valore ID) o complesso (composto da più campi). I cursori semplici sono la chiave primaria di un record, ad esempio, id autoincrementale o UUID. I cursori compositi vengono utilizzati per ordinare per campi non univoci, ad esempio (created_at, id), dove id garantisce l’unicità quando i timestamp sono identici.
Un formato API tipico è un cursore come stringa codificata in base64. Il server decodifica il cursore, estrae il valore e costruisce la query SQL. La codifica Base64 nasconde la struttura interna del cursore al client e consente di modificare il formato senza rompere la compatibilità all’indietro. Il client riceve i cursori nel campo endCursor della risposta e li passa come stringa nella richiesta successiva.
La paginazione tramite cursore supporta la navigazione bidirezionale. Per il movimento in avanti (next), si usa il cursore dell’ultimo elemento della pagina corrente; per indietro (previous), il cursore del primo elemento. I parametri after e before nella richiesta determinano la direzione: after prende i record dopo il cursore, before prende i record prima del cursore.
La scelta tra paginazione tramite cursore e Offset è una delle decisioni architettoniche chiave nella progettazione di un’API. Ogni metodo ha punti di forza e debolezza che determinano la sua applicabilità. Cursor pagination vince negli scenari con dati dinamici; Offset vince negli scenari con navigazione arbitraria.
| Caratteristica | Cursor | Offset |
|---|---|---|
| Stabilità nelle inserzioni | Alta (nessun duplicato) | Bassa (spostamento pagine) |
| Prestazioni su grandi insiemi | O(log n) — stabile | O(n) — degrada con la crescita |
| Navigazione per numero di pagina | No | Sì (page=5) |
| Complessità di implementazione | Media | Bassa |
| Supporto REST | cursor/before/after | page/offset |
| Supporto GraphQL | Standard Relay | Non raccomandato |
La paginazione Offset esegue una scansione completa della tabella fino alla posizione OFFSET. Con offset=100000, il database legge e salta 100000 righe, anche se LIMIT è 20. MySQL e PostgreSQL non possono ottimizzare OFFSET — questa è una caratteristica implementativa di LIMIT/OFFSET in SQL. La paginazione tramite cursore utilizza un indice B-tree che trova la posizione in O(log n).
Un problema aggiuntivo di Offset è il “salto” di record durante la paginazione all’indietro. Se un utente ha caricato la pagina 5 e in quel momento sono stati aggiunti nuovi record, quando richiede la pagina 6 vedrà di nuovo il record dalla pagina 5 o perderà quelli nuovi. Cursor pagination elimina completamente questo scenario: il cursore punta a un punto specifico nell’insieme e le inserzioni non cambiano la posizione.
Vediamo l’implementazione della paginazione tramite cursore sul backend (Kotlin + Spring) e sul client (Android + Retrofit). Il server accetta i parametri after, before, limit e restituisce un elenco di record con cursori e pageInfo. Una risposta tipica contiene hasNextPage e hasPreviousPage per gestire l’interfaccia di paginazione.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Sul client, la paginazione tramite cursore viene implementata tramite PagingSource di Paging 3, dove la chiave è un cursore (Long). PagingSource.load riceve LoadParams.key — il cursore dell’ultimo record caricato. LoadResult.Page restituisce i dati e nextKey — il cursore per la pagina successiva. Quando nextKey = null, la paginazione è completa.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
In GraphQL, la paginazione tramite cursore viene implementata tramite il pattern Relay Connection. Ogni tipo ha una Connection (con pageInfo e edges) e un Edge (node + cursor). La query passa i parametri first, after, last, before. Il server restituisce un array di edges con cursori e pageInfo con hasNextPage/hasPreviousPage.
La paginazione tramite cursore è raccomandata per le API che lavorano con dati dinamici dove i record vengono frequentemente aggiunti o eliminati. Esempi classici: feed di notizie in un social network, messaggi di chat, cronologia transazioni, commenti ai post. In tutti questi scenari, la coerenza e l’assenza di duplicati sono importanti.
Esistono scenari in cui la paginazione Offset è più conveniente: pannelli di amministrazione dove è necessaria la navigazione per numero di pagina; ricerca con paginazione dove i risultati possono cambiare; report e analisi dove è necessario un link fisso alla pagina 5. In questi casi, i vantaggi del cursore non superano la complessità di implementazione.
La paginazione tramite cursore non supporta il “salto” a una pagina arbitraria — l’utente non può cliccare su “Pagina 5” e andarci. Questa è una limitazione architettonica: contare il numero totale di pagine richiede una query COUNT separata, che può essere costosa per tabelle grandi. In questi casi, un approccio ibrido: cursore per i dati + count per la paginazione.
Domande frequenti
Un cursore è un identificatore univoco di record che punta a una posizione nell’insieme di dati. Può essere semplice (un ID di record) o composito (più campi). Il client riceve il cursore dell’ultimo record della pagina e lo passa nella richiesta successiva per ottenere il lotto successivo.
Cursor pagination non è soggetto a spostamento quando vengono aggiunti nuovi record — ogni elemento cade esattamente in una pagina. Mantiene inoltre la velocità su grandi volumi utilizzando indici invece di scansionare le prime n righe. Offset è più semplice ma instabile per dati dinamici.
Sì, la paginazione tramite cursore non è legata a GraphQL. Può essere implementata in qualsiasi API REST passando il cursore come parametro di query ?after=83&limit=20. La risposta deve contenere pageInfo con endCursor e hasNextPage — questo consente al client di gestire il caricamento senza conoscere la struttura interna del cursore.
ID autoincrementale è la scelta ottimale: aumenta monotonamente, non cambia, viene indicizzato efficientemente. Anche UUID v7 (ordinato per tempo) funziona. I timestamp possono produrre duplicati allo stesso momento, quindi combinalo con ID: (created_at, id) per garantire l’unicità del cursore.
La paginazione tramite cursore non fornisce il numero totale di pagine — questa è la sua limitazione. Se hai bisogno di informazioni totali, esegui una query COUNT separata con gli stessi filtri. Per tabelle grandi, usa un conteggio approssimativo tramite EXPLAIN o un totale memorizzato nella cache dalle analisi.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche